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(
abceg
")} />,
);
expect(html).toBe('');
});
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('');
}
});
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("filegnp.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", "