From 7e21b728b346e80023c93e8986130f83e70584f9 Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 8 Apr 2026 21:10:35 +0800 Subject: [PATCH] fix: harden memo content iframe and HTML sanitization --- .../components/MemoContent/TrustedIframe.ts | 10 +++ web/src/components/MemoContent/constants.ts | 81 +++++++++---------- web/src/components/MemoContent/index.tsx | 2 + web/src/index.css | 2 +- web/tests/memo-content-security.test.mjs | 67 +++++++++++++++ 5 files changed, 116 insertions(+), 46 deletions(-) create mode 100644 web/src/components/MemoContent/TrustedIframe.ts create mode 100644 web/tests/memo-content-security.test.mjs diff --git a/web/src/components/MemoContent/TrustedIframe.ts b/web/src/components/MemoContent/TrustedIframe.ts new file mode 100644 index 000000000..0fdd8c40e --- /dev/null +++ b/web/src/components/MemoContent/TrustedIframe.ts @@ -0,0 +1,10 @@ +import { createElement } from "react"; +import { isTrustedIframeSrc } from "./constants"; + +export const TrustedIframe = (props: React.ComponentProps<"iframe">) => { + if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) { + return null; + } + + return createElement("iframe", props); +}; diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index f49ae76ff..60c456593 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -17,13 +17,30 @@ export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: SNIPPET: { textKey: "memo.show-less", next: "ALL" }, }; +const TRUSTED_IFRAME_SRC_PATTERNS = [ + /^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i, + /^https:\/\/www\.youtube-nocookie\.com\/embed\/[^?#]+(?:\?.*)?$/i, + /^https:\/\/player\.vimeo\.com\/video\/[^?#]+(?:\?.*)?$/i, + /^https:\/\/open\.spotify\.com\/embed\/[^?#]+(?:\?.*)?$/i, + /^https:\/\/w\.soundcloud\.com\/player\/?(?:\?.*)?$/i, + /^https:\/\/www\.loom\.com\/embed\/[^?#]+(?:\?.*)?$/i, + /^https:\/\/www\.google\.com\/maps\/embed(?:\/[^?#]*)?(?:\?.*)?$/i, + /^https:\/\/(?:app\.)?diagrams\.net\/(?:[^?#]+)?(?:\?.*)?$/i, + /^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i, +]; + +const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const; +const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const; +const SPAN_CLASS_NAMES = ["mention", "tag"] as const; + +export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src)); + /** * Sanitization schema for markdown HTML content. * Extends the default schema to allow: - * - KaTeX math rendering elements (MathML tags) - * - KaTeX-specific attributes (className, style, aria-*, data-*) - * - Safe HTML elements for rich content - * - iframe embeds for trusted video providers (YouTube, Vimeo, etc.) + * - KaTeX marker classes used before trusted KaTeX rendering runs + * - Mention/tag metadata generated by trusted remark plugins + * - iframe embeds only from trusted video providers * * This prevents XSS attacks while preserving math rendering functionality. */ @@ -31,50 +48,24 @@ export const SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, - div: [...(defaultSchema.attributes?.div || []), "className"], img: [...(defaultSchema.attributes?.img || []), "height", "width"], - span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]], - // iframe attributes for video embeds - iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"], - // MathML attributes for KaTeX rendering - annotation: ["encoding"], - math: ["xmlns"], - mi: [], - mn: [], - mo: [], - mrow: [], - mspace: [], - mstyle: [], - msup: [], - msub: [], - msubsup: [], - mfrac: [], - mtext: [], - semantics: [], + code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]], + span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]], + iframe: [ + ["src", ...TRUSTED_IFRAME_SRC_PATTERNS], + "width", + "height", + "frameborder", + "allowfullscreen", + "allow", + "title", + "referrerpolicy", + "loading", + ], }, - tagNames: [ - ...(defaultSchema.tagNames || []), - // iframe for video embeds - "iframe", - // MathML elements for KaTeX math rendering - "math", - "annotation", - "semantics", - "mi", - "mn", - "mo", - "mrow", - "mspace", - "mstyle", - "msup", - "msub", - "msubsup", - "mfrac", - "mtext", - ], + tagNames: [...(defaultSchema.tagNames || []), "iframe"], protocols: { ...defaultSchema.protocols, - // Allow HTTPS iframe embeds only for security - iframe: { src: ["https"] }, + src: ["https"], }, }; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index f482b4761..dd07dacc9 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -25,6 +25,7 @@ import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, Lis import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table"; import { Tag } from "./Tag"; import { TaskListItem } from "./TaskListItem"; +import { TrustedIframe } from "./TrustedIframe"; import type { MemoContentProps } from "./types"; function getMentionUsername(node: Element, children?: React.ReactNode): string { @@ -148,6 +149,7 @@ const MemoContent = (props: MemoContentProps) => { // Inline elements a: ({ children, ...props }) => {children}, code: ({ children, ...props }) => {children}, + iframe: TrustedIframe as React.ComponentType>, img: ({ ...props }) => , // Code blocks pre: CodeBlock, diff --git a/web/src/index.css b/web/src/index.css index 5bef77ee6..53d49382c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -18,7 +18,7 @@ * Embedded Content * ======================================== */ - /* iframes (e.g., YouTube embeds, maps) */ + /* iframes from trusted embed providers */ iframe { max-width: 100%; border-radius: 0.5rem; diff --git a/web/tests/memo-content-security.test.mjs b/web/tests/memo-content-security.test.mjs new file mode 100644 index 000000000..6dcc3ff23 --- /dev/null +++ b/web/tests/memo-content-security.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkMath from "remark-math"; +import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "../src/components/MemoContent/constants.ts"; + +const TrustedIframe = (props) => { + if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) { + return null; + } + + return React.createElement("iframe", props); +}; + +const renderMemoContent = (content) => + renderToStaticMarkup( + React.createElement(ReactMarkdown, { + children: content, + remarkPlugins: [remarkMath], + rehypePlugins: [rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], [rehypeKatex, { throwOnError: false, strict: false }]], + components: { + iframe: TrustedIframe, + }, + }), + ); + +test("strips user-controlled inline styles from raw HTML spans", () => { + const html = renderMemoContent('overlay'); + + assert.match(html, /overlay<\/span>/); + assert.doesNotMatch(html, /style=/); + assert.doesNotMatch(html, /position:fixed/); +}); + +test("still renders KaTeX output after sanitizing math marker classes", () => { + const html = renderMemoContent("$L$"); + + assert.match(html, /class="katex"/); + assert.match(html, /class="katex-html"/); +}); + +test("allows trusted iframe providers only", () => { + assert.equal(isTrustedIframeSrc("https://www.youtube.com/embed/abc123"), true); + assert.equal(isTrustedIframeSrc("https://www.youtube-nocookie.com/embed/abc123?si=test"), true); + assert.equal(isTrustedIframeSrc("https://player.vimeo.com/video/123456"), true); + assert.equal(isTrustedIframeSrc("https://open.spotify.com/embed/track/123456"), true); + assert.equal(isTrustedIframeSrc("https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456"), true); + assert.equal(isTrustedIframeSrc("https://www.loom.com/embed/123456"), true); + assert.equal(isTrustedIframeSrc("https://www.google.com/maps/embed?pb=test"), true); + assert.equal(isTrustedIframeSrc("https://app.diagrams.net/?embed=1"), true); + assert.equal(isTrustedIframeSrc("https://www.draw.io/?embed=1"), true); + assert.equal(isTrustedIframeSrc("https://evil.example/embed/abc123"), false); +}); + +test("drops untrusted iframe embeds during rendering", () => { + const trusted = renderMemoContent(''); + const untrusted = renderMemoContent(''); + + assert.match(trusted, /