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
Takagi 2024-06-26 18:24:50 +08:00 committed by GitHub
parent ed6a5e3898
commit 5aacd8a252
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 54 additions and 20 deletions

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { isAllowedUri } from "@/utils/is-allowed-uri";
import { computed, type Component } from "vue";
import Iframe from "./index";
@ -18,6 +19,9 @@ const src = computed({
return props.editor.getAttributes(Iframe.name).src;
},
set: (src: string) => {
if (!src || !isAllowedUri(src)) {
return;
}
props.editor.chain().updateAttributes(Iframe.name, { src: src }).run();
},
});

View File

@ -1,9 +1,10 @@
<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 { NodeViewWrapper } from "@/tiptap/vue-3";
import { isAllowedUri } from "@/utils/is-allowed-uri";
import { computed, onMounted, ref } from "vue";
import { i18n } from "@/locales";
const props = defineProps<{
editor: Editor;
@ -21,6 +22,9 @@ const src = computed({
return props.node?.attrs.src;
},
set: (src: string) => {
if (!src || !isAllowedUri(src)) {
return;
}
props.updateAttributes({ src: src });
},
});

View File

@ -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 {
Editor,
Node,
VueNodeViewRenderer,
isActive,
mergeAttributes,
Node,
nodeInputRule,
nodePasteRule,
type Range,
VueNodeViewRenderer,
} 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 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 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 MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiDesktopMac from "~icons/mdi/desktop-mac";
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 { deleteNode } from "@/utils";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import MdiShare from "~icons/mdi/share";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
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 BubbleIframeLink from "./BubbleItemIframeLink.vue";
import BubbleIframeSize from "./BubbleItemIframeSize.vue";
import IframeView from "./IframeView.vue";
declare module "@/tiptap" {
interface Commands<ReturnType> {
@ -144,11 +145,24 @@ const Iframe = Node.create<ExtensionOptions>({
return [
{
tag: "iframe",
getAttrs: (dom) => {
const src = (dom as HTMLElement).getAttribute("src");
// prevent XSS attacks
if (!src || !isAllowedUri(src)) {
return false;
}
return { src };
},
},
];
},
renderHTML({ HTMLAttributes }) {
// prevent XSS attacks
if (!isAllowedUri(HTMLAttributes.src)) {
return ["iframe", mergeAttributes({ ...HTMLAttributes, src: "" })];
}
return ["iframe", mergeAttributes(HTMLAttributes)];
},

View File

@ -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);
}