fix: harden memo content iframe and HTML sanitization
This commit is contained in:
parent
2d682ae176
commit
7e21b728b3
5 changed files with 116 additions and 46 deletions
10
web/src/components/MemoContent/TrustedIframe.ts
Normal file
10
web/src/components/MemoContent/TrustedIframe.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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"],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }) => <Link {...props}>{children}</Link>,
|
||||
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
|
||||
iframe: TrustedIframe as React.ComponentType<React.ComponentProps<"iframe">>,
|
||||
img: ({ ...props }) => <Image {...props} />,
|
||||
// Code blocks
|
||||
pre: CodeBlock,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
67
web/tests/memo-content-security.test.mjs
Normal file
67
web/tests/memo-content-security.test.mjs
Normal file
|
|
@ -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('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
|
||||
|
||||
assert.match(html, /<span>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('<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>');
|
||||
const untrusted = renderMemoContent('<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>');
|
||||
|
||||
assert.match(trusted, /<iframe/);
|
||||
assert.match(trusted, /youtube\.com\/embed\/abc123/);
|
||||
assert.doesNotMatch(untrusted, /<iframe/);
|
||||
});
|
||||
Loading…
Reference in a new issue