mirror of https://github.com/halo-dev/halo
pref: editor iframe risk with src tag (#6150)
#### What type of PR is this? /kind improvement /area editor /milestone 2.17.x #### What this PR does / why we need it: 在用户设置 iframe 相关的 src 时,检测设置的链接是否符合白名单。如果不符合则不允许设置。 see https://github.com/ueberdosis/tiptap/pull/5160 #### How to test it? 测试在 iframe 中的 src 输入 `javascript: alert("1")` 时是否会触发 javascript #### Does this PR introduce a user-facing change? ```release-note 处理默认编辑器中 iframe 标签的 src 属性可能存在的风险 ```pull/6152/head^2
parent
ed6a5e3898
commit
5aacd8a252
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { i18n } from "@/locales";
|
import { i18n } from "@/locales";
|
||||||
import type { Editor } from "@/tiptap/vue-3";
|
import type { Editor } from "@/tiptap/vue-3";
|
||||||
|
import { isAllowedUri } from "@/utils/is-allowed-uri";
|
||||||
import { computed, type Component } from "vue";
|
import { computed, type Component } from "vue";
|
||||||
import Iframe from "./index";
|
import Iframe from "./index";
|
||||||
|
|
||||||
|
@ -18,6 +19,9 @@ const src = computed({
|
||||||
return props.editor.getAttributes(Iframe.name).src;
|
return props.editor.getAttributes(Iframe.name).src;
|
||||||
},
|
},
|
||||||
set: (src: string) => {
|
set: (src: string) => {
|
||||||
|
if (!src || !isAllowedUri(src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
props.editor.chain().updateAttributes(Iframe.name, { src: src }).run();
|
props.editor.chain().updateAttributes(Iframe.name, { src: src }).run();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
|
import { i18n } from "@/locales";
|
||||||
|
import type { Decoration, Node as ProseMirrorNode } from "@/tiptap/pm";
|
||||||
import type { Editor, Node } from "@/tiptap/vue-3";
|
import type { Editor, Node } from "@/tiptap/vue-3";
|
||||||
import { NodeViewWrapper } from "@/tiptap/vue-3";
|
import { NodeViewWrapper } from "@/tiptap/vue-3";
|
||||||
|
import { isAllowedUri } from "@/utils/is-allowed-uri";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { i18n } from "@/locales";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
@ -21,6 +22,9 @@ const src = computed({
|
||||||
return props.node?.attrs.src;
|
return props.node?.attrs.src;
|
||||||
},
|
},
|
||||||
set: (src: string) => {
|
set: (src: string) => {
|
||||||
|
if (!src || !isAllowedUri(src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
props.updateAttributes({ src: src });
|
props.updateAttributes({ src: src });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,37 +1,38 @@
|
||||||
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
|
import { BlockActionSeparator } from "@/components";
|
||||||
|
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
|
||||||
|
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
|
||||||
|
import { i18n } from "@/locales";
|
||||||
|
import type { EditorState } from "@/tiptap/pm";
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
|
Node,
|
||||||
|
VueNodeViewRenderer,
|
||||||
isActive,
|
isActive,
|
||||||
mergeAttributes,
|
mergeAttributes,
|
||||||
Node,
|
|
||||||
nodeInputRule,
|
nodeInputRule,
|
||||||
nodePasteRule,
|
nodePasteRule,
|
||||||
type Range,
|
type Range,
|
||||||
VueNodeViewRenderer,
|
|
||||||
} from "@/tiptap/vue-3";
|
} from "@/tiptap/vue-3";
|
||||||
import type { EditorState } from "@/tiptap/pm";
|
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
|
||||||
|
import { deleteNode } from "@/utils";
|
||||||
|
import { isAllowedUri } from "@/utils/is-allowed-uri";
|
||||||
import { markRaw } from "vue";
|
import { markRaw } from "vue";
|
||||||
import IframeView from "./IframeView.vue";
|
|
||||||
import MdiWeb from "~icons/mdi/web";
|
|
||||||
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
|
|
||||||
import { i18n } from "@/locales";
|
|
||||||
import { BlockActionSeparator } from "@/components";
|
|
||||||
import BubbleIframeSize from "./BubbleItemIframeSize.vue";
|
|
||||||
import BubbleIframeLink from "./BubbleItemIframeLink.vue";
|
|
||||||
import MdiBorderAllVariant from "~icons/mdi/border-all-variant";
|
import MdiBorderAllVariant from "~icons/mdi/border-all-variant";
|
||||||
import MdiBorderNoneVariant from "~icons/mdi/border-none-variant";
|
import MdiBorderNoneVariant from "~icons/mdi/border-none-variant";
|
||||||
import MdiDesktopMac from "~icons/mdi/desktop-mac";
|
|
||||||
import MdiTabletIpad from "~icons/mdi/tablet-ipad";
|
|
||||||
import MdiCellphoneIphone from "~icons/mdi/cellphone-iphone";
|
import MdiCellphoneIphone from "~icons/mdi/cellphone-iphone";
|
||||||
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
|
import MdiDesktopMac from "~icons/mdi/desktop-mac";
|
||||||
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
|
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
|
||||||
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
|
|
||||||
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
|
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
|
||||||
import { deleteNode } from "@/utils";
|
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
|
||||||
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
|
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
|
||||||
import MdiShare from "~icons/mdi/share";
|
|
||||||
import MdiLinkVariant from "~icons/mdi/link-variant";
|
import MdiLinkVariant from "~icons/mdi/link-variant";
|
||||||
|
import MdiShare from "~icons/mdi/share";
|
||||||
|
import MdiTabletIpad from "~icons/mdi/tablet-ipad";
|
||||||
|
import MdiWeb from "~icons/mdi/web";
|
||||||
import MdiWebSync from "~icons/mdi/web-sync";
|
import MdiWebSync from "~icons/mdi/web-sync";
|
||||||
|
import BubbleIframeLink from "./BubbleItemIframeLink.vue";
|
||||||
|
import BubbleIframeSize from "./BubbleItemIframeSize.vue";
|
||||||
|
import IframeView from "./IframeView.vue";
|
||||||
|
|
||||||
declare module "@/tiptap" {
|
declare module "@/tiptap" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
@ -144,11 +145,24 @@ const Iframe = Node.create<ExtensionOptions>({
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "iframe",
|
tag: "iframe",
|
||||||
|
getAttrs: (dom) => {
|
||||||
|
const src = (dom as HTMLElement).getAttribute("src");
|
||||||
|
|
||||||
|
// prevent XSS attacks
|
||||||
|
if (!src || !isAllowedUri(src)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return { src };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
// prevent XSS attacks
|
||||||
|
if (!isAllowedUri(HTMLAttributes.src)) {
|
||||||
|
return ["iframe", mergeAttributes({ ...HTMLAttributes, src: "" })];
|
||||||
|
}
|
||||||
return ["iframe", mergeAttributes(HTMLAttributes)];
|
return ["iframe", mergeAttributes(HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
// From DOMPurify
|
||||||
|
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.js
|
||||||
|
// see https://github.com/ueberdosis/tiptap/pull/5160
|
||||||
|
|
||||||
|
const ATTR_WHITESPACE =
|
||||||
|
/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g; // eslint-disable-line no-control-regex
|
||||||
|
const IS_ALLOWED_URI =
|
||||||
|
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; // eslint-disable-line no-useless-escape
|
||||||
|
|
||||||
|
export function isAllowedUri(uri: string | undefined) {
|
||||||
|
return !uri || uri.replace(ATTR_WHITESPACE, "").match(IS_ALLOWED_URI);
|
||||||
|
}
|
Loading…
Reference in New Issue