fix xss in generic iframe embed (#1419)

This commit is contained in:
Philip Okugbe
2025-07-29 19:28:48 +01:00
committed by GitHub
parent 78bce0e29d
commit 6b627d289c
5 changed files with 42 additions and 11 deletions

View File

@ -21,6 +21,7 @@ import i18n from "i18next";
import { import {
getEmbedProviderById, getEmbedProviderById,
getEmbedUrlAndProvider, getEmbedUrlAndProvider,
sanitizeUrl,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { ResizableWrapper } from "../common/resizable-wrapper"; import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css"; import classes from "./embed-view.module.css";
@ -51,9 +52,12 @@ export default function EmbedView(props: NodeViewProps) {
validate: zodResolver(schema), validate: zodResolver(schema),
}); });
const handleResize = useCallback((newHeight: number) => { const handleResize = useCallback(
updateAttributes({ height: newHeight }); (newHeight: number) => {
}, [updateAttributes]); updateAttributes({ height: newHeight });
},
[updateAttributes],
);
async function onSubmit(data: { url: string }) { async function onSubmit(data: { url: string }) {
if (!editor.isEditable) { if (!editor.isEditable) {
@ -63,11 +67,11 @@ export default function EmbedView(props: NodeViewProps) {
if (provider) { if (provider) {
const embedProvider = getEmbedProviderById(provider); const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") { if (embedProvider.id === "iframe") {
updateAttributes({ src: data.url }); updateAttributes({ src: sanitizeUrl(data.url) });
return; return;
} }
if (embedProvider.regex.test(data.url)) { if (embedProvider.regex.test(data.url)) {
updateAttributes({ src: data.url }); updateAttributes({ src: sanitizeUrl(data.url) });
} else { } else {
notifications.show({ notifications.show({
message: t("Invalid {{provider}} embed link", { message: t("Invalid {{provider}} embed link", {
@ -95,7 +99,7 @@ export default function EmbedView(props: NodeViewProps) {
> >
<iframe <iframe
className={classes.embedIframe} className={classes.embedIframe}
src={embedUrl} src={sanitizeUrl(embedUrl)}
allow="encrypted-media" allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen allowFullScreen

View File

@ -18,6 +18,7 @@
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"" "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@hocuspocus/extension-redis": "^2.15.2", "@hocuspocus/extension-redis": "^2.15.2",
"@hocuspocus/provider": "^2.15.2", "@hocuspocus/provider": "^2.15.2",

View File

@ -1,5 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from '@tiptap/react';
import { sanitizeUrl } from './utils';
export interface EmbedOptions { export interface EmbedOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@ -40,9 +41,12 @@ export const Embed = Node.create<EmbedOptions>({
return { return {
src: { src: {
default: '', default: '',
parseHTML: (element) => element.getAttribute('data-src'), parseHTML: (element) => {
const src = element.getAttribute('data-src');
return sanitizeUrl(src);
},
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-src': attributes.src, 'data-src': sanitizeUrl(attributes.src),
}), }),
}, },
provider: { provider: {
@ -85,6 +89,9 @@ export const Embed = Node.create<EmbedOptions>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes["data-src"];
const safeHref = sanitizeUrl(src);
return [ return [
"div", "div",
mergeAttributes( mergeAttributes(
@ -95,10 +102,10 @@ export const Embed = Node.create<EmbedOptions>({
[ [
"a", "a",
{ {
href: HTMLAttributes["data-src"], href: safeHref,
target: "blank", target: "blank",
}, },
`${HTMLAttributes["data-src"]}`, safeHref,
], ],
]; ];
}, },
@ -108,9 +115,15 @@ export const Embed = Node.create<EmbedOptions>({
setEmbed: setEmbed:
(attrs: EmbedAttributes) => (attrs: EmbedAttributes) =>
({ commands }) => { ({ commands }) => {
// Validate the URL before inserting
const validatedAttrs = {
...attrs,
src: sanitizeUrl(attrs.src),
};
return commands.insertContent({ return commands.insertContent({
type: 'embed', type: 'embed',
attrs: attrs, attrs: validatedAttrs,
}); });
}, },
}; };

View File

@ -4,6 +4,7 @@ import { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model"; import { Node, ResolvedPos } from "@tiptap/pm/model";
import Table from "@tiptap/extension-table"; import Table from "@tiptap/extension-table";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
export const isRectSelected = (rect: any) => (selection: CellSelection) => { export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1)); const map = TableMap.get(selection.$anchorCell.node(-1));
@ -379,3 +380,12 @@ export function setAttributes(
export function icon(name: string) { export function icon(name: string) {
return `<span class="ProseMirror-icon ProseMirror-icon-${name}"></span>`; return `<span class="ProseMirror-icon ProseMirror-icon-${name}"></span>`;
} }
export function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
const sanitized = braintreeSanitizeUrl(url);
// Return empty string instead of "about:blank"
return sanitized === "about:blank" ? "" : sanitized;
}

3
pnpm-lock.yaml generated
View File

@ -16,6 +16,9 @@ importers:
.: .:
dependencies: dependencies:
'@braintree/sanitize-url':
specifier: ^7.1.0
version: 7.1.0
'@docmost/editor-ext': '@docmost/editor-ext':
specifier: workspace:* specifier: workspace:*
version: link:packages/editor-ext version: link:packages/editor-ext