Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
207 lines
9.2 KiB
TypeScript
207 lines
9.2 KiB
TypeScript
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<string, string>();
|
||
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(<RichText doc={doc} />);
|
||
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(
|
||
<RichText doc={parseProviderHtml("<p>a<script>alert(1)</script>b<style>x{}</style>c<iframe>d</iframe>e<svg><text>f</text></svg>g</p>")} />,
|
||
);
|
||
expect(html).toBe('<div dir="auto"><p dir="auto">abceg</p></div>');
|
||
});
|
||
|
||
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(<RichText doc={parseProviderHtml(`<a href="${href}">label</a>`)} />);
|
||
expect(html).toBe('<div dir="auto"><p dir="auto">label</p></div>');
|
||
}
|
||
});
|
||
|
||
it("shows the real destination host when the label names another host", () => {
|
||
const html = renderToStaticMarkup(<RichText doc={parseProviderHtml('<a href="https://evil.example/login">https://bank.example/login</a>')} />);
|
||
expect(html).toContain('<span class="sc-link-host"> (evil.example)</span>');
|
||
});
|
||
|
||
it("exposes homoglyph IDN hosts as punycode", () => {
|
||
const doc = parseProviderHtml('<a href="https://pаypal.com/">paypal.com</a>');
|
||
const link = doc.children[0]!.children[0]!;
|
||
expect(link).toMatchObject({ type: "link", host: "xn--pypal-4ve.com" });
|
||
expect(renderToStaticMarkup(<RichText doc={doc} />)).toContain("(xn--pypal-4ve.com)");
|
||
});
|
||
|
||
it("strips bidi overrides and isolates from body text", () => {
|
||
const doc = parseProviderHtml("<p>filegnp.exe x</p>");
|
||
expect(JSON.stringify(doc)).not.toMatch(/[--]/);
|
||
});
|
||
|
||
it("emits entity-encoded markup only as escaped text", () => {
|
||
const html = renderToStaticMarkup(<RichText doc={parseProviderHtml("<script>alert(1)</script>")} />);
|
||
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(<RichText doc={parsePlainText(payload, { emojis })} />);
|
||
assertInert(html);
|
||
assertNoAttackerRequests(html);
|
||
});
|
||
|
||
it("never links javascript:/data: or protocol-relative text", () => {
|
||
const html = renderToStaticMarkup(<RichText doc={parsePlainText("javascript:alert(1) data:text/html,x //evil.example/x")} />);
|
||
expect(html).not.toContain("<a");
|
||
});
|
||
});
|
||
|
||
describe("link-preview payloads", () => {
|
||
it.each(MALICIOUS_LINK_PREVIEWS.map((p, i) => [i, p]))("renders inert: #%s", (_i, preview) => {
|
||
const html = renderToStaticMarkup(<LinkPreviewCard preview={preview} />);
|
||
assertInert(html);
|
||
assertNoAttackerRequests(html);
|
||
});
|
||
|
||
it("rejects unsafe targets entirely", () => {
|
||
expect(renderToStaticMarkup(<LinkPreviewCard preview={{ url: "javascript:alert(1)", title: "x" }} />)).toBe("");
|
||
});
|
||
|
||
it("routes preview images through the media proxy", () => {
|
||
const html = renderToStaticMarkup(<LinkPreviewCard preview={{ url: "https://ok.example/", image: "https://evil.example/og.png" }} />);
|
||
expect(html).toContain(`src="${PROXY_PREFIX}https%3A%2F%2Fevil.example%2Fog.png"`);
|
||
});
|
||
|
||
it("caps preview text length", () => {
|
||
const html = renderToStaticMarkup(<LinkPreviewCard preview={{ url: "https://ok.example/", title: "x".repeat(100_000) }} />);
|
||
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(<RichText doc={doc} />);
|
||
assertInert(html);
|
||
assertNoAttackerRequests(html);
|
||
expect(html).not.toMatch(/<[^>]*onerror/i);
|
||
});
|
||
});
|
||
|
||
describe("display names", () => {
|
||
it("removes bidi and zero-width spoofing characters", () => {
|
||
const html = renderToStaticMarkup(<DisplayName name={"adminnimda <script>"} />);
|
||
expect(html).toBe("<bdi>adminnimda <script></bdi>");
|
||
});
|
||
|
||
it("flags mixed-script homoglyph names", () => {
|
||
expect(renderToStaticMarkup(<DisplayName name={"pаypal"} />)).toContain('data-confusable="true"');
|
||
expect(renderToStaticMarkup(<DisplayName name="paypal" />)).not.toContain("data-confusable");
|
||
});
|
||
});
|
||
|
||
describe("huge and deeply nested input", () => {
|
||
const cases: [string, string][] = [
|
||
["1MB of '<'", "<".repeat(1_000_000)],
|
||
["unterminated attribute", "<a href=\"" + "x".repeat(1_000_000)],
|
||
["100k nested divs", "<div>".repeat(100_000) + "deep" + "</div>".repeat(100_000)],
|
||
["100k nested svg", "<svg>".repeat(100_000) + "<img src=x onerror=alert(1)>"],
|
||
["50k links", '<a href="https://ok.example/">x</a>'.repeat(50_000)],
|
||
["comment bomb", "<!--".repeat(200_000)],
|
||
["attribute flood", "<a " + "x=1 ".repeat(200_000) + 'href="https://ok.example/">x</a>'],
|
||
["unclosed raw text", "<script>" + "a".repeat(1_000_000)],
|
||
];
|
||
|
||
it.each(cases)("%s: bounded time and output, inert", (_name, payload) => {
|
||
const start = performance.now();
|
||
const doc = parseProviderHtml(payload);
|
||
const html = renderToStaticMarkup(<RichText doc={doc} />);
|
||
expect(performance.now() - start).toBeLessThan(2_000);
|
||
expect(html.length).toBeLessThan(200_000);
|
||
assertInert(html);
|
||
expect(doc.truncated).toBe(true);
|
||
});
|
||
|
||
it("plain text: huge input is clipped", () => {
|
||
const doc = parsePlainText("https://ok.example/ @a #b\n".repeat(200_000));
|
||
expect(doc.truncated).toBe(true);
|
||
assertInert(renderToStaticMarkup(<RichText doc={doc} />));
|
||
});
|
||
});
|