Commit 7e21b728 authored by boojack's avatar boojack

fix: harden memo content iframe and HTML sanitization

parent 2d682ae1
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: [],
},
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",
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"],
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;
......
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/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment