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, /