import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { ATTACKER_ORIGIN, FORGED_DOCUMENTS, MALICIOUS_HTML, MALICIOUS_LINK_PREVIEWS, MALICIOUS_PLAIN } from "./corpus.js"; import { parseProviderHtml } from "./html.js"; import { parsePlainText } from "./plain.js"; import { DisplayName, LinkPreviewCard, RichText } from "./react.js"; import { parseSafeHttpUrl } from "./url.js"; const PROXY_PREFIX = "/api/media-proxy?url="; const ALLOWED_TAGS = new Set(["div", "p", "a", "span", "br", "img", "strong", "bdi"]); const ALLOWED_ATTRS = new Set([ "class", "dir", "href", "rel", "target", "referrerpolicy", "src", "alt", "title", "loading", "decoding", "draggable", "data-truncated", "data-confusable", ]); function unescapeAttr(value: string): string { return value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); } /** * Asserts rendered markup is inert: only allowlisted tags/attributes, every * href an absolute credential-free http(s) URL with the safe rel, every src * routed through the media proxy (so nothing is fetched from an attacker * origin), and no text that the browser could re-parse as markup. */ function assertInert(html: string): void { const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[a-zA-Z-]+(?:="[^"]*")?)*)\s*\/?>/g; const stripped = html.replace(tagRe, ""); // Anything left that looks like a tag would mean unescaped markup leaked out. expect(stripped).not.toMatch(/<[a-zA-Z!/?]/); for (const match of html.matchAll(tagRe)) { const [, closing, tag, attrs = ""] = match; if (closing) { expect(attrs.trim()).toBe(""); continue; } expect(ALLOWED_TAGS, `tag <${tag}> in ${html}`).toContain(tag!.toLowerCase()); const attrMap = new Map(); for (const attr of attrs.matchAll(/([a-zA-Z-]+)(?:="([^"]*)")?/g)) { const name = attr[1]!.toLowerCase(); const value = unescapeAttr(attr[2] ?? ""); expect(ALLOWED_ATTRS, `attribute ${name} in ${html}`).toContain(name); expect(name).not.toMatch(/^on/); attrMap.set(name, value); if (name === "href") { expect(value).toMatch(/^https?:\/\/[^/]/); expect(parseSafeHttpUrl(value)?.href).toBe(value); } if (name === "src") { expect(value.startsWith(PROXY_PREFIX), `src ${value}`).toBe(true); expect(value).not.toMatch(/^\/\//); } if (name !== "href" && name !== "src") expect(value).not.toMatch(/(javascript|vbscript|data):/i); } if (tag === "a") { expect(attrMap.get("rel")).toBe("noopener noreferrer nofollow ugc"); expect(attrMap.get("referrerpolicy")).toBe("no-referrer"); } if (tag === "img") expect(attrMap.get("referrerpolicy")).toBe("no-referrer"); } expect(html).not.toMatch(/dangerouslySetInnerHTML|srcset|srcdoc|style=/i); } /** Every request-causing attribute (src) targets our own origin only. */ function assertNoAttackerRequests(html: string): void { for (const m of html.matchAll(/\ssrc="([^"]*)"/g)) { expect(m[1]!.startsWith(PROXY_PREFIX)).toBe(true); } expect(html).not.toMatch(new RegExp(`src="https?://[^"]*${ATTACKER_ORIGIN.replace(".", "\\.")}`)); } const emojis = [ { shortcode: "blobcat", url: "https://cdn.ok.example/blobcat.png" }, { shortcode: "evil", url: "javascript:alert(1)" }, { shortcode: "x\" onerror=\"alert(1)", url: "https://cdn.ok.example/x.png" }, ]; describe("malicious HTML corpus", () => { it.each(MALICIOUS_HTML.map((p) => [p]))("renders inert: %s", (payload) => { const doc = parseProviderHtml(payload, { emojis }); const html = renderToStaticMarkup(); assertInert(html); assertNoAttackerRequests(html); expect(JSON.stringify(doc)).not.toMatch(/javascript:|vbscript:|"data:/i); }); it("drops script/style/iframe content rather than showing it", () => { const html = renderToStaticMarkup( abcefg

")} />, ); expect(html).toBe('

abceg

'); }); it("renders unsafe-scheme links as non-clickable text", () => { for (const href of ["javascript:alert(1)", "data:text/html,x", "vbscript:x", "//evil.example/", "https://u:p@evil.example/", "file:///etc/passwd"]) { const html = renderToStaticMarkup(label`)} />); expect(html).toBe('

label

'); } }); it("shows the real destination host when the label names another host", () => { const html = renderToStaticMarkup(https://bank.example/login')} />); expect(html).toContain(' (evil.example)'); }); it("exposes homoglyph IDN hosts as punycode", () => { const doc = parseProviderHtml('paypal.com'); const link = doc.children[0]!.children[0]!; expect(link).toMatchObject({ type: "link", host: "xn--pypal-4ve.com" }); expect(renderToStaticMarkup()).toContain("(xn--pypal-4ve.com)"); }); it("strips bidi overrides and isolates from body text", () => { const doc = parseProviderHtml("

file‮gnp.exe ⁦x⁩

"); expect(JSON.stringify(doc)).not.toMatch(/[‪-‮⁦-⁩]/); }); it("emits entity-encoded markup only as escaped text", () => { const html = renderToStaticMarkup(); expect(html).toContain("<script>alert(1)</script>"); }); }); describe("malicious plain-text corpus", () => { it.each(MALICIOUS_PLAIN.map((p) => [p]))("renders inert: %s", (payload) => { const html = renderToStaticMarkup(); assertInert(html); assertNoAttackerRequests(html); }); it("never links javascript:/data: or protocol-relative text", () => { const html = renderToStaticMarkup(); expect(html).not.toContain(" { it.each(MALICIOUS_LINK_PREVIEWS.map((p, i) => [i, p]))("renders inert: #%s", (_i, preview) => { const html = renderToStaticMarkup(); assertInert(html); assertNoAttackerRequests(html); }); it("rejects unsafe targets entirely", () => { expect(renderToStaticMarkup()).toBe(""); }); it("routes preview images through the media proxy", () => { const html = renderToStaticMarkup(); expect(html).toContain(`src="${PROXY_PREFIX}https%3A%2F%2Fevil.example%2Fog.png"`); }); it("caps preview text length", () => { const html = renderToStaticMarkup(); expect(html.length).toBeLessThan(2_000); }); }); describe("forged ASTs", () => { it.each(FORGED_DOCUMENTS.map((d, i) => [i, d]))("are re-validated at render: #%s", (_i, doc) => { const html = renderToStaticMarkup(); assertInert(html); assertNoAttackerRequests(html); expect(html).not.toMatch(/<[^>]*onerror/i); }); }); describe("display names", () => { it("removes bidi and zero-width spoofing characters", () => { const html = renderToStaticMarkup("} />); expect(html).toBe("adminnimda <script>"); }); it("flags mixed-script homoglyph names", () => { expect(renderToStaticMarkup()).toContain('data-confusable="true"'); expect(renderToStaticMarkup()).not.toContain("data-confusable"); }); }); describe("huge and deeply nested input", () => { const cases: [string, string][] = [ ["1MB of '<'", "<".repeat(1_000_000)], ["unterminated attribute", "".repeat(100_000) + "deep" + "".repeat(100_000)], ["100k nested svg", "".repeat(100_000) + ""], ["50k links", 'x'.repeat(50_000)], ["comment bomb", "