Tactical Media Broadcast Hub — initial release

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/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,59 @@
/**
* Closed rich-text AST for provider content (proposal §4.5 "rich content
* contract"). Nothing outside these node types can be rendered: there is no
* node that carries raw HTML, attributes, styles or arbitrary URLs.
*
* URL-bearing fields are only ever produced by `parseSafeHttpUrl` /
* `toProxiedMediaUrl`, and the renderer re-validates them, so an AST that was
* round-tripped through storage or the network cannot smuggle a bad scheme.
*/
export const RICH_TEXT_VERSION = 1 as const;
export type TextNode = { readonly type: "text"; readonly text: string };
export type LineBreakNode = { readonly type: "linebreak" };
/** Custom emoji / emote. `src` is always a media-proxy URL, never the origin URL. */
export type EmojiNode = { readonly type: "emoji"; readonly shortcode: string; readonly src: string };
export type LinkNode = {
readonly type: "link";
/** Absolute http(s) URL, credential-free. */
readonly href: string;
/** ASCII (punycode) host of `href`, shown to the operator next to the label. */
readonly host: string;
readonly children: readonly (TextNode | EmojiNode)[];
};
export type MentionNode = {
readonly type: "mention";
/** Handle without the leading "@", e.g. `alice` or `alice@example.social`. */
readonly handle: string;
readonly href?: string;
};
export type HashtagNode = {
readonly type: "hashtag";
/** Tag without the leading "#". */
readonly tag: string;
readonly href?: string;
};
export type InlineNode = TextNode | LineBreakNode | EmojiNode | LinkNode | MentionNode | HashtagNode;
export type ParagraphNode = { readonly type: "paragraph"; readonly children: readonly InlineNode[] };
export type RichTextDocument = {
readonly type: "doc";
readonly version: typeof RICH_TEXT_VERSION;
readonly children: readonly ParagraphNode[];
/** True when input exceeded a length/node/depth limit and was cut. */
readonly truncated: boolean;
};
export type LinkPreview = {
readonly type: "linkPreview";
readonly href: string;
readonly host: string;
readonly title?: string;
readonly description?: string;
readonly siteName?: string;
/** Media-proxy URL for the preview image. */
readonly image?: string;
};

View file

@ -0,0 +1,175 @@
import type { EmojiNode, InlineNode, LinkNode, ParagraphNode, RichTextDocument, TextNode } from "./ast.js";
import { RICH_TEXT_VERSION } from "./ast.js";
import { DEFAULT_LIMITS, type SafeContentLimits } from "./limits.js";
import { neutralizeText } from "./text.js";
import { DEFAULT_MEDIA_PROXY, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js";
/** Provider-supplied custom emoji (Mastodon `emojis[]`, Twitch emote sets, …). */
export type CustomEmoji = { readonly shortcode: string; readonly url: string };
export type ParseOptions = {
readonly limits?: Partial<SafeContentLimits>;
readonly mediaProxy?: MediaProxyPolicy;
readonly emojis?: readonly CustomEmoji[];
};
const SHORTCODE = /^[A-Za-z0-9_+-]{1,64}$/;
const SHORTCODE_IN_TEXT = /:([A-Za-z0-9_+-]{1,64}):/g;
const MAX_EMOJIS = 500;
export function normalizeHandle(raw: string): string | null {
const handle = neutralizeText(raw).trim().replace(/^@+/, "");
return /^[\p{L}\p{N}_.-]{1,64}(?:@[\p{L}\p{N}.-]{1,253})?$/u.test(handle) ? handle : null;
}
export function normalizeTag(raw: string): string | null {
const tag = neutralizeText(raw).trim().replace(/^#+/, "");
return /^[\p{L}\p{N}\p{M}_]{1,100}$/u.test(tag) ? tag : null;
}
/** Accumulates a document while enforcing node limits; every write is sanitised. */
export class DocBuilder {
readonly limits: SafeContentLimits;
readonly mediaProxy: MediaProxyPolicy;
private readonly emojis = new Map<string, string>();
private readonly paragraphs: ParagraphNode[] = [];
private current: InlineNode[] = [];
private nodes = 0;
private chars = 0;
truncated = false;
constructor(options: ParseOptions = {}) {
this.limits = { ...DEFAULT_LIMITS, ...options.limits };
this.mediaProxy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY;
for (const emoji of (options.emojis ?? []).slice(0, MAX_EMOJIS)) {
if (typeof emoji?.shortcode !== "string" || !SHORTCODE.test(emoji.shortcode)) continue;
const src = toProxiedMediaUrl(emoji.url, this.mediaProxy);
if (src) this.emojis.set(emoji.shortcode, src);
}
}
/** Clips raw input to the configured length, flagging truncation. */
clip(input: string): string {
if (input.length <= this.limits.maxInputLength) return input;
this.truncated = true;
return input.slice(0, this.limits.maxInputLength);
}
private take(): boolean {
if (this.nodes >= this.limits.maxNodes) {
this.truncated = true;
return false;
}
this.nodes++;
return true;
}
/** Splits text into text + emoji nodes. */
inlineText(raw: string): (TextNode | EmojiNode)[] {
let text = neutralizeText(raw);
// Total text budget; parse paths are already clipped, this bounds forged ASTs.
const room = this.limits.maxInputLength - this.chars;
if (text.length > room) {
this.truncated = true;
text = text.slice(0, Math.max(room, 0));
}
this.chars += text.length;
if (text === "") return [];
const out: (TextNode | EmojiNode)[] = [];
if (this.emojis.size === 0) return [{ type: "text", text }];
let last = 0;
for (const match of text.matchAll(SHORTCODE_IN_TEXT)) {
const shortcode = match[1] as string;
const src = this.emojis.get(shortcode);
if (!src) continue;
if (match.index > last) out.push({ type: "text", text: text.slice(last, match.index) });
out.push({ type: "emoji", shortcode, src });
last = match.index + match[0].length;
}
if (last < text.length) out.push({ type: "text", text: text.slice(last) });
return out;
}
private push(node: InlineNode): void {
const prev = this.current[this.current.length - 1];
if (node.type === "text" && prev?.type === "text") {
this.current[this.current.length - 1] = { type: "text", text: prev.text + node.text };
return;
}
if (this.take()) this.current.push(node);
}
/** Appends an already-sanitised text/emoji node. */
pushLeaf(node: TextNode | EmojiNode): void {
this.push(node);
}
text(raw: string): void {
for (const node of this.inlineText(raw)) this.push(node);
}
lineBreak(): void {
this.push({ type: "linebreak" });
}
/** Emits a link if `href` is safe, otherwise the label as inert text. */
link(href: string, label: readonly (TextNode | EmojiNode)[]): void {
const safe = parseSafeHttpUrl(href, this.limits.maxUrlLength);
if (!safe) {
for (const node of label) this.push(node);
return;
}
const children = label.length > 0 ? mergeText(label) : [{ type: "text" as const, text: safe.host }];
if (this.nodes + children.length > this.limits.maxNodes) {
this.truncated = true;
return;
}
this.nodes += children.length;
this.push({ type: "link", href: safe.href, host: safe.host, children } satisfies LinkNode);
}
mention(rawHandle: string, href?: string): void {
const handle = normalizeHandle(rawHandle);
if (!handle) return this.text(rawHandle);
const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength);
this.push(safe ? { type: "mention", handle, href: safe.href } : { type: "mention", handle });
}
hashtag(rawTag: string, href?: string): void {
const tag = normalizeTag(rawTag);
if (!tag) return this.text(rawTag);
const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength);
this.push(safe ? { type: "hashtag", tag, href: safe.href } : { type: "hashtag", tag });
}
endParagraph(): void {
const children = trimParagraph(this.current);
this.current = [];
// Not budgeted: each non-empty paragraph already holds >= 1 budgeted inline node.
if (children.length > 0) this.paragraphs.push({ type: "paragraph", children });
}
finish(): RichTextDocument {
this.endParagraph();
return { type: "doc", version: RICH_TEXT_VERSION, children: this.paragraphs, truncated: this.truncated };
}
}
function mergeText(nodes: readonly (TextNode | EmojiNode)[]): (TextNode | EmojiNode)[] {
const out: (TextNode | EmojiNode)[] = [];
for (const node of nodes) {
const prev = out[out.length - 1];
if (node.type === "text" && prev?.type === "text") out[out.length - 1] = { type: "text", text: prev.text + node.text };
else out.push(node);
}
return out;
}
function trimParagraph(nodes: InlineNode[]): InlineNode[] {
let start = 0;
let end = nodes.length;
const blank = (n: InlineNode | undefined) => n?.type === "linebreak" || (n?.type === "text" && n.text.trim() === "");
while (start < end && blank(nodes[start])) start++;
while (end > start && blank(nodes[end - 1])) end--;
return nodes.slice(start, end);
}

View file

@ -0,0 +1,207 @@
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(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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>file‮gnp.exe ⁦x⁩</p>");
expect(JSON.stringify(doc)).not.toMatch(/[‪-‮⁦-⁩]/);
});
it("emits entity-encoded markup only as escaped text", () => {
const html = renderToStaticMarkup(<RichText doc={parseProviderHtml("&lt;script&gt;alert(1)&lt;/script&gt;")} />);
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});
});
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={"ad​min‮nimda <script>"} />);
expect(html).toBe("<bdi>adminnimda &lt;script&gt;</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} />));
});
});

View file

@ -0,0 +1,140 @@
/**
* Malicious provider-content corpus (proposal §4.5 acceptance). Exported so
* `packages/testing` and the Playwright suites can replay the same payloads
* through connector mocks.
*/
export const ATTACKER_ORIGIN = "evil.example";
export const MALICIOUS_HTML: readonly string[] = [
// Script injection
"<script>alert(1)</script>",
"<SCRIPT SRC=//evil.example/x.js></SCRIPT>",
"<script src=https://evil.example/x.js>",
"<scr<script>ipt>alert(1)</scr</script>ipt>",
"<<script>script>alert(1)<</script>/script>",
"<p>hi<script>alert(1)</p>",
"<plaintext><script>alert(1)</script>",
// Event handlers / attributes
"<img src=x onerror=alert(1)>",
"<img src=\"https://evil.example/pixel.gif\">",
"<img srcset=\"https://evil.example/a.png 1x\" alt=\"pic\">",
"<svg onload=alert(1)>",
"<svg><script>alert(1)</script><a xlink:href=\"javascript:alert(1)\">x</a></svg>",
"<body onload=alert(1)>",
"<p onclick=\"alert(1)\" style=\"background:url(https://evil.example/t.png)\">click</p>",
"<a href=\"https://ok.example/\" onclick=\"alert(1)\" onmouseover=alert(1) style=\"position:fixed\">ok</a>",
"<div style=\"background-image:url('https://evil.example/t.png')\">css</div>",
"<details open ontoggle=alert(1)>",
"<input autofocus onfocus=alert(1)>",
"<video><source onerror=alert(1)></video>",
"<marquee onstart=alert(1)>x</marquee>",
// Dangerous schemes
"<a href=\"javascript:alert(1)\">x</a>",
"<a href=\"JaVaScRiPt:alert(1)\">x</a>",
"<a href=\" javascript:alert(1)\">x</a>",
"<a href=\"java&#x09;script:alert(1)\">x</a>",
"<a href=\"java\tscript:alert(1)\">x</a>",
"<a href=\"&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;alert(1)\">x</a>",
"<a href=\"&#x6A;avascript&colon;alert(1)\">x</a>",
"<a href=\"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\">x</a>",
"<a href=\"vbscript:msgbox(1)\">x</a>",
"<a href=\"file:///etc/passwd\">x</a>",
"<a href=\"//evil.example/x\">x</a>",
"<a href=\"/\\evil.example/x\">x</a>",
"<a href=\"https:evil.example\">x</a>",
"<a href=\"https://user:pass@evil.example/\">creds</a>",
"<a href=\"https://ok.example@evil.example/\">at-trick</a>",
// Container elements
"<iframe src=\"https://evil.example/\"></iframe>",
"<iframe srcdoc=\"<script>alert(1)</script>\"></iframe>",
"<object data=\"https://evil.example/x.swf\"></object>",
"<embed src=\"https://evil.example/x.swf\">",
"<form action=\"https://evil.example/\"><input name=x><button>go</button></form>",
"<base href=\"https://evil.example/\">",
"<meta http-equiv=\"refresh\" content=\"0;url=https://evil.example/\">",
"<link rel=stylesheet href=\"https://evil.example/x.css\">",
"<style>@import 'https://evil.example/x.css'; body{background:url(https://evil.example/t.png)}</style>",
"<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>",
"<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">",
"<textarea><img src=x onerror=alert(1)></textarea>",
"<template><img src=x onerror=alert(1)></template>",
"<svg><foreignObject><iframe src=javascript:alert(1)></iframe></foreignObject></svg>",
// Encoding / comment / malformed evasions
"&lt;script&gt;alert(1)&lt;/script&gt;",
"<!--<script>alert(1)</script>-->",
"<!--><img src=x onerror=alert(1)>-->",
"<![CDATA[<script>alert(1)</script>]]>",
"<?xml-stylesheet href=\"https://evil.example/x.xsl\"?>",
"<a href=\"https://ok.example/\"<script>alert(1)</script>",
"<a href=\"https://ok.example/",
"<a href='https://ok.example/' title='\"><script>alert(1)</script>'>quotes</a>",
"<a href=https://ok.example/ ><img src=x onerror=alert(1)></a>",
"</p></a></div></span><img src=x onerror=alert(1)>",
"<a href=\"https://ok.example/\"><a href=\"javascript:alert(1)\">nested</a></a>",
"<span class=\"invisible\"><script>alert(1)</script></span>",
// Spoofing
"<a href=\"https://evil.example/login\">https://bank.example/login</a>",
"<a href=\"https://pаypal.com/\">paypal.com</a>",
"<p>file‮gnp.exe and zero​width⁦isolate⁩</p>",
];
export const MALICIOUS_PLAIN: readonly string[] = [
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"click javascript://%0aalert(1) now",
"data:text/html,<script>alert(1)</script>",
"https://evil.example/\"><script>alert(1)</script>",
"see https://user:pass@evil.example/ and //evil.example",
"@alice@evil.example #tag <b>bold</b>",
"‮txt.exe ​‍",
"&lt;script&gt;",
];
export const MALICIOUS_LINK_PREVIEWS: readonly unknown[] = [
{ url: "javascript:alert(1)", title: "x" },
{ url: "data:text/html,<script>alert(1)</script>", title: "x" },
{ url: "//evil.example/", title: "protocol-relative" },
{ url: "https://ok.example/", title: "<script>alert(1)</script>", description: "<img src=x onerror=alert(1)>", image: "https://evil.example/og.png" },
{ url: "https://ok.example/", image: "javascript:alert(1)" },
{ url: "https://ok.example/", image: "data:image/svg+xml,<svg onload=alert(1)>" },
{ url: "https://ok.example/", image: "//evil.example/og.png" },
{ url: "https://ok.example/", siteName: "‮lapyap", title: "x".repeat(100_000) },
{ type: "linkPreview", href: "javascript:alert(1)", host: "ok.example" },
{ type: "linkPreview", href: "https://ok.example/", host: "ok.example", image: "https://evil.example/direct.png" },
{ url: { toString: () => "javascript:alert(1)" } },
null,
"https://ok.example/",
];
/** Hand-forged ASTs, as if from a tampered DB row or websocket frame. */
export const FORGED_DOCUMENTS: readonly unknown[] = [
{
type: "doc",
version: 1,
truncated: false,
children: [
{
type: "paragraph",
children: [
{ type: "link", href: "javascript:alert(1)", host: "ok.example", children: [{ type: "text", text: "x" }] },
{ type: "link", href: "https://ok.example/", host: "ok.example", children: [{ type: "html", html: "<script>alert(1)</script>" }] },
{ type: "emoji", shortcode: "x", src: "https://evil.example/e.png" },
{ type: "emoji", shortcode: "x", src: "/api/media-proxy?url=javascript%3Aalert(1)" },
{ type: "emoji", shortcode: "x\"onerror=alert(1)", src: "/api/media-proxy?url=https%3A%2F%2Fok.example%2Fe.png" },
{ type: "html", html: "<img src=x onerror=alert(1)>" },
{ type: "text", text: "<img src=x onerror=alert(1)>" },
{ type: "mention", handle: "x\"><script>", href: "javascript:alert(1)" },
{ type: "hashtag", tag: "ok", href: "data:text/html,x" },
{ type: "text", text: { toString: () => "<script>" } },
],
},
{ type: "script", children: [] },
"<script>alert(1)</script>",
],
},
{ type: "doc", children: "nope" },
"<script>alert(1)</script>",
null,
[],
];

View file

@ -0,0 +1,18 @@
const NAMED: Record<string, string> = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00A0",
hellip: "…", mdash: "—", ndash: "–", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”",
copy: "©", reg: "®", trade: "™", laquo: "«", raquo: "»", middot: "·", bull: "•",
};
/** Decodes HTML character references to plain text. Output is still only ever a React text node. */
export function decodeEntities(input: string): string {
if (!input.includes("&")) return input;
return input.replace(/&(?:#[xX]([0-9a-fA-F]{1,8})|#([0-9]{1,10})|([A-Za-z]{2,8}));?/g, (whole, hex, dec, name) => {
if (hex !== undefined || dec !== undefined) {
const code = hex !== undefined ? parseInt(hex, 16) : parseInt(dec, 10);
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "<22>";
return String.fromCodePoint(code);
}
return Object.hasOwn(NAMED, name) && whole.endsWith(";") ? (NAMED[name] as string) : whole;
});
}

View file

@ -0,0 +1,31 @@
/**
* Flat-config block banning raw-HTML sinks (proposal §4.5 (1)). Spread it into
* the root `eslint.config.mjs`; `packages/safe-content/**` is the only
* sanctioned renderer and is excluded via `ignores`.
*
* import { noRawHtmlSinks } from "@tactical-media-broadcast-hub/safe-content/eslint";
* export default tseslint.config(..., noRawHtmlSinks);
*/
const MESSAGE = "Raw HTML sink — render provider content only through @tactical-media-broadcast-hub/safe-content (proposal §4.5).";
export const RAW_HTML_SINK_SELECTORS: readonly string[] = [
"JSXAttribute[name.name='dangerouslySetInnerHTML']",
"Property[key.name='dangerouslySetInnerHTML']",
"AssignmentExpression > MemberExpression.left[property.name=/^(innerHTML|outerHTML)$/]",
"CallExpression[callee.property.name=/^(insertAdjacentHTML|createContextualFragment|parseFromString|setHTMLUnsafe)$/]",
"CallExpression[callee.object.name='document'][callee.property.name=/^(write|writeln)$/]",
"NewExpression[callee.name='Function']",
"CallExpression[callee.name='eval']",
];
export const noRawHtmlSinks: {
files: string[];
ignores: string[];
rules: { "no-restricted-syntax": ["error", ...{ selector: string; message: string }[]] };
} = {
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
ignores: ["packages/safe-content/**"],
rules: {
"no-restricted-syntax": ["error", ...RAW_HTML_SINK_SELECTORS.map((selector) => ({ selector, message: MESSAGE }))],
},
};

View file

@ -0,0 +1,269 @@
import type { EmojiNode, RichTextDocument, TextNode } from "./ast.js";
import { DocBuilder, type ParseOptions } from "./builder.js";
import { decodeEntities } from "./entities.js";
/*
* Converts the Mastodon/PeerTube HTML subset into the rich-text AST. This is
* a converter, not an HTML renderer: no tag or attribute survives as markup.
* Recognised structure (p, br, a, Mastodon mention/hashtag/invisible/ellipsis
* spans) maps to AST nodes; dangerous containers are dropped with their
* content; every other tag is unwrapped to its text. The tokenizer is a single
* forward scan (no regex backtracking), so huge or malformed input is linear.
*/
/** Element content parsed as raw text by browsers; skipped up to the matching end tag. */
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
/** Elements whose entire content is discarded. */
const DROP_CONTENT = new Set([
"svg", "math", "template", "object", "embed", "applet", "frame", "frameset", "head", "select", "option",
"form", "button", "audio", "video", "canvas", "map", "picture", "source", "track", "dialog", "portal",
]);
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
const PARAGRAPH = new Set([
"p", "div", "blockquote", "pre", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"section", "article", "header", "footer", "aside", "table", "tr", "dl", "dt", "dd", "figure", "figcaption", "hr",
]);
type Token =
| { kind: "text"; text: string }
| { kind: "start"; name: string; attrs: Map<string, string>; selfClosing: boolean }
| { kind: "end"; name: string };
const MAX_ATTRS = 16;
function isAlpha(c: number): boolean {
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
function isSpace(c: number): boolean {
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
}
function* tokenize(src: string): Generator<Token> {
const lower = src.toLowerCase();
const n = src.length;
let i = 0;
while (i < n) {
const lt = src.indexOf("<", i);
if (lt === -1) {
yield { kind: "text", text: src.slice(i) };
return;
}
if (lt > i) yield { kind: "text", text: src.slice(i, lt) };
i = lt;
const next = src.charCodeAt(i + 1);
if (src.startsWith("<!--", i)) {
const close = src.indexOf("-->", i + 4);
i = close === -1 ? n : close + 3;
continue;
}
if (next === 33 /* ! */ || next === 63 /* ? */ || src.startsWith("</>", i)) {
// Bogus comment / doctype / processing instruction / "</>".
const close = src.indexOf(">", i + 1);
i = close === -1 ? n : close + 1;
continue;
}
const isEnd = next === 47;
const nameStart = isEnd ? i + 2 : i + 1;
if (!isAlpha(src.charCodeAt(nameStart))) {
yield { kind: "text", text: "<" };
i++;
continue;
}
let j = nameStart;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== "/" && src[j] !== ">") j++;
const name = lower.slice(nameStart, j);
// Attributes: name[=value], value quoted or unquoted.
const attrs = new Map<string, string>();
let selfClosing = false;
while (j < n && src[j] !== ">") {
const c = src.charCodeAt(j);
if (isSpace(c)) { j++; continue; }
if (src[j] === "/") { selfClosing = true; j++; continue; }
selfClosing = false;
const an = j;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== "/" && src[j] !== ">" && src[j] !== "=") j++;
if (j === an) j++; // stray "=": consume to guarantee progress
const attrName = lower.slice(an, j);
let value = "";
while (j < n && isSpace(src.charCodeAt(j))) j++;
if (src[j] === "=") {
j++;
while (j < n && isSpace(src.charCodeAt(j))) j++;
const q = src[j];
if (q === '"' || q === "'") {
const close = src.indexOf(q, j + 1);
const end = close === -1 ? n : close;
value = src.slice(j + 1, end);
j = end + 1;
} else {
const vs = j;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== ">") j++;
value = src.slice(vs, j);
}
}
if (attrName && !attrs.has(attrName) && attrs.size < MAX_ATTRS) attrs.set(attrName, decodeEntities(value));
}
i = j + 1;
if (isEnd) {
yield { kind: "end", name };
continue;
}
if (name === "plaintext") return;
if (RAW_TEXT.has(name)) {
const close = lower.indexOf("</" + name, i);
i = close === -1 ? n : (() => {
const gt = src.indexOf(">", close);
return gt === -1 ? n : gt + 1;
})();
continue;
}
yield { kind: "start", name, attrs, selfClosing };
}
}
type Frame = { name: string; role: "drop" | "link" | "invisible" | "ellipsis" | "other" };
type OpenLink = { href: string; classes: Set<string>; rel: Set<string>; label: (TextNode | EmojiNode)[] };
function words(value: string | undefined): Set<string> {
return new Set((value ?? "").toLowerCase().split(/\s+/).filter(Boolean));
}
/** Mastodon/PeerTube-style HTML → rich-text AST. */
export function parseProviderHtml(input: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder(options);
if (typeof input !== "string") return builder.finish();
const src = builder.clip(input);
const stack: Frame[] = [];
let dropDepth = 0;
let invisibleDepth = 0;
let link: OpenLink | null = null;
const flushLink = () => {
if (!link) return;
const current = link;
link = null;
const labelText = current.label.map((n) => (n.type === "text" ? n.text : `:${n.shortcode}:`)).join("").trim();
if (current.classes.has("mention") && (current.classes.has("hashtag") || current.rel.has("tag"))) {
builder.hashtag(labelText, current.href);
} else if (current.classes.has("mention")) {
builder.mention(handleFor(labelText, current.href), current.href);
} else {
builder.link(current.href, current.label);
}
};
const emitText = (raw: string) => {
if (dropDepth > 0) return;
if (link && invisibleDepth > 0) return;
const text = decodeEntities(raw).replace(/[\t\n\f\r ]+/g, " ");
if (link) link.label.push(...builder.inlineText(text));
else builder.text(text);
};
const close = (index: number) => {
while (stack.length > index) {
const frame = stack.pop() as Frame;
if (frame.role === "drop") dropDepth--;
else if (frame.role === "invisible") invisibleDepth--;
else if (frame.role === "ellipsis" && link && dropDepth === 0) link.label.push({ type: "text", text: "…" });
else if (frame.role === "link") flushLink();
if (PARAGRAPH.has(frame.name) && dropDepth === 0) {
flushLink();
builder.endParagraph();
}
}
};
for (const token of tokenize(src)) {
if (token.kind === "text") {
emitText(token.text);
continue;
}
if (token.kind === "end") {
if (token.name === "br" && dropDepth === 0) builder.lineBreak(); // "</br>" acts as <br> in browsers
for (let k = stack.length - 1; k >= 0; k--) {
const frame = stack[k] as Frame;
// Inside dropped content only the dropped elements' own end tags count.
if (frame.name === token.name && (dropDepth === 0 || frame.role === "drop")) {
close(k);
break;
}
}
continue;
}
const { name, attrs } = token;
if (dropDepth > 0) {
if (DROP_CONTENT.has(name) && !VOID.has(name) && !token.selfClosing && stack.length < builder.limits.maxDepth) {
stack.push({ name, role: "drop" });
dropDepth++;
}
continue;
}
if (name === "br") {
if (link) link.label.push({ type: "text", text: " " });
else builder.lineBreak();
continue;
}
if (name === "img") {
const alt = attrs.get("alt") ?? "";
if (alt) emitText(alt);
continue;
}
if (PARAGRAPH.has(name)) {
flushLink();
builder.endParagraph();
}
if (VOID.has(name) || token.selfClosing && !DROP_CONTENT.has(name) && name !== "a" && name !== "span") continue;
if (stack.length >= builder.limits.maxDepth) {
// Too deep: stop tracking structure, keep text (inert) and flag it.
builder.truncated = true;
if (DROP_CONTENT.has(name)) {
// Can't track the matching end tag any more; drop the rest of the input.
break;
}
continue;
}
if (DROP_CONTENT.has(name)) {
if (token.selfClosing) continue;
stack.push({ name, role: "drop" });
dropDepth++;
continue;
}
if (name === "a") {
flushLink();
link = { href: attrs.get("href") ?? "", classes: words(attrs.get("class")), rel: words(attrs.get("rel")), label: [] };
stack.push({ name, role: "link" });
continue;
}
if (name === "span" && link) {
const classes = words(attrs.get("class"));
if (classes.has("invisible")) {
invisibleDepth++;
stack.push({ name, role: "invisible" });
continue;
}
if (classes.has("ellipsis")) {
stack.push({ name, role: "ellipsis" });
continue;
}
}
stack.push({ name, role: "other" });
}
close(0);
flushLink();
return builder.finish();
}
/** Mastodon renders only the local username in mention text; qualify it with the href host. */
function handleFor(label: string, href: string): string {
const handle = label.replace(/^@+/, "");
if (handle.includes("@")) return handle;
try {
const url = new URL(href);
if (url.protocol === "http:" || url.protocol === "https:") return `${handle}@${url.hostname}`;
} catch {
// fall through: unqualified handle
}
return handle;
}

View file

@ -0,0 +1,15 @@
export * from "./ast.js";
export { DEFAULT_LIMITS, LINK_PREVIEW_LIMITS, type SafeContentLimits } from "./limits.js";
export { hasMixedScriptConfusables, neutralizeDisplayName, neutralizeText } from "./text.js";
export {
DEFAULT_MEDIA_PROXY,
isProxiedMediaUrl,
parseSafeHttpUrl,
toProxiedMediaUrl,
type MediaProxyPolicy,
type SafeUrl,
} from "./url.js";
export type { CustomEmoji, ParseOptions } from "./builder.js";
export { parsePlainText } from "./plain.js";
export { parseProviderHtml } from "./html.js";
export { coerceRichTextDocument, parseLinkPreview, type RawLinkPreview } from "./validate.js";

View file

@ -0,0 +1,23 @@
export type SafeContentLimits = {
/** Max input characters considered; the rest is dropped and `truncated` set. */
readonly maxInputLength: number;
/** Max element nesting tracked by the HTML parser; deeper structure is flattened to text. */
readonly maxDepth: number;
/** Max AST nodes emitted per document. */
readonly maxNodes: number;
/** Max characters in any single URL. */
readonly maxUrlLength: number;
};
export const DEFAULT_LIMITS: SafeContentLimits = {
maxInputLength: 20_000,
maxDepth: 32,
maxNodes: 2_000,
maxUrlLength: 2_048,
};
export const LINK_PREVIEW_LIMITS = {
title: 300,
description: 1_000,
siteName: 100,
} as const;

View file

@ -0,0 +1,179 @@
import { Linter } from "eslint";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { noRawHtmlSinks } from "./eslint.js";
import { parseProviderHtml } from "./html.js";
import { parsePlainText } from "./plain.js";
import { RichText } from "./react.js";
import { isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl } from "./url.js";
import { coerceRichTextDocument, parseLinkPreview } from "./validate.js";
describe("Mastodon HTML", () => {
const status =
'<p><span class="h-card" translate="no"><a href="https://social.example/@alice" class="u-url mention">@<span>alice</span></a></span> ' +
'look :blobcat: <a href="https://social.example/tags/fediverse" class="mention hashtag" rel="tag">#<span>fediverse</span></a></p>' +
'<p>read <a href="https://news.example/articles/2026/very-long-path" target="_blank" rel="nofollow noopener noreferrer" translate="no">' +
'<span class="invisible">https://</span><span class="ellipsis">news.example/articles/2026/v</span><span class="invisible">ery-long-path</span></a><br>bye</p>';
it("maps mentions, hashtags, links, emoji and breaks to the AST", () => {
const doc = parseProviderHtml(status, { emojis: [{ shortcode: "blobcat", url: "https://cdn.example/blobcat.png" }] });
expect(doc).toEqual({
type: "doc",
version: 1,
truncated: false,
children: [
{
type: "paragraph",
children: [
{ type: "mention", handle: "alice@social.example", href: "https://social.example/@alice" },
{ type: "text", text: " look " },
{ type: "emoji", shortcode: "blobcat", src: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fblobcat.png" },
{ type: "text", text: " " },
{ type: "hashtag", tag: "fediverse", href: "https://social.example/tags/fediverse" },
],
},
{
type: "paragraph",
children: [
{ type: "text", text: "read " },
{
type: "link",
href: "https://news.example/articles/2026/very-long-path",
host: "news.example",
children: [{ type: "text", text: "news.example/articles/2026/v…" }],
},
{ type: "linebreak" },
{ type: "text", text: "bye" },
],
},
],
});
});
it("renders the expected markup", () => {
const html = renderToStaticMarkup(<RichText doc={parseProviderHtml(status)} />);
expect(html).toContain('<a href="https://social.example/@alice" rel="noopener noreferrer nofollow ugc" target="_blank" referrerPolicy="no-referrer" class="sc-mention">@alice@social.example</a>');
expect(html).toContain(":blobcat:");
expect(html).not.toContain("sc-link-host");
});
});
describe("plain text", () => {
it("linkifies http(s) URLs, mentions, hashtags and newlines", () => {
const doc = parsePlainText("hi @bob@x.example see https://a.example/p?q=1. #tag\nline2\n\npara2");
expect(doc.children).toHaveLength(2);
expect(doc.children[0]!.children).toEqual([
{ type: "text", text: "hi " },
{ type: "mention", handle: "bob@x.example" },
{ type: "text", text: " see " },
{ type: "link", href: "https://a.example/p?q=1", host: "a.example", children: [{ type: "text", text: "a.example/p?q=1" }] },
{ type: "text", text: ". " },
{ type: "hashtag", tag: "tag" },
{ type: "linebreak" },
{ type: "text", text: "line2" },
]);
});
it("ignores non-string input", () => {
expect(parsePlainText(42).children).toEqual([]);
});
});
describe("URL policy", () => {
it.each([
"javascript:alert(1)", " javascript:alert(1)", "JAVASCRIPT:alert(1)", "java\tscript:alert(1)", "data:text/html,x",
"vbscript:x", "file:///etc/passwd", "//evil.example", "/relative", "https:evil.example", "https:/evil.example",
"https://user@evil.example/", "https://:pw@evil.example/", "http://evil.example/\u0000", "https://evil.example/‮",
"ftp://evil.example/", "https://" + "a".repeat(3_000) + ".example/", "",
])("rejects %j", (raw) => {
expect(parseSafeHttpUrl(raw)).toBeNull();
});
it("accepts http(s) and normalises", () => {
expect(parseSafeHttpUrl("HTTPS://Ok.Example/a b".replace(" ", "%20"))).toEqual({ href: "https://ok.example/a%20b", host: "ok.example" });
});
it("media proxy URLs are canonical and same-origin", () => {
const src = toProxiedMediaUrl("https://cdn.example/a.png");
expect(src).toBe("/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fa.png");
expect(isProxiedMediaUrl(src)).toBe(true);
expect(isProxiedMediaUrl("https://cdn.example/a.png")).toBe(false);
expect(isProxiedMediaUrl("/api/media-proxy?url=https://cdn.example/a.png")).toBe(false);
expect(isProxiedMediaUrl("/api/media-proxy?url=javascript%3Aalert(1)")).toBe(false);
expect(toProxiedMediaUrl("data:image/png;base64,AAAA")).toBeNull();
expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "//evil.example/p" })).toThrow();
expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "https://evil.example/p" })).toThrow();
});
it("supports a custom proxy path end to end", () => {
const mediaProxy = { proxyPath: "/media/p" };
const doc = parseProviderHtml("hi :e:", { mediaProxy, emojis: [{ shortcode: "e", url: "https://cdn.example/e.png" }] });
const html = renderToStaticMarkup(<RichText doc={doc} mediaProxy={mediaProxy} />);
expect(html).toContain('src="/media/p?url=https%3A%2F%2Fcdn.example%2Fe.png"');
// Same doc rendered against a different proxy: emoji degrades to text.
expect(renderToStaticMarkup(<RichText doc={doc} />)).not.toContain("<img");
});
});
describe("coerceRichTextDocument", () => {
it("round-trips a valid document unchanged", () => {
const doc = parseProviderHtml('<p>a <a href="https://ok.example/">ok.example</a> @x #y</p>');
expect(coerceRichTextDocument(JSON.parse(JSON.stringify(doc)))).toEqual(doc);
});
it("re-applies node limits", () => {
const forged = { type: "doc", children: [{ type: "paragraph", children: Array.from({ length: 10_000 }, (_, i) => (i % 2 ? { type: "linebreak" } : { type: "mention", handle: "a" })) }] };
const doc = coerceRichTextDocument(forged);
expect(doc.truncated).toBe(true);
expect(doc.children[0]!.children.length).toBeLessThanOrEqual(2_000);
});
it("bounds total text from forged nodes", () => {
const forged = { type: "doc", children: [{ type: "paragraph", children: [{ type: "text", text: "x".repeat(5_000_000) }] }] };
const doc = coerceRichTextDocument(forged);
expect(doc.truncated).toBe(true);
expect(JSON.stringify(doc).length).toBeLessThan(25_000);
});
});
describe("parseLinkPreview", () => {
it("builds a preview with proxied image and plain-text fields", () => {
expect(parseLinkPreview({ url: "https://ok.example/a", title: "A &amp; <b>B</b>", siteName: "Ok", image: "https://cdn.example/i.png" })).toEqual({
type: "linkPreview",
href: "https://ok.example/a",
host: "ok.example",
title: "A & <b>B</b>",
siteName: "Ok",
image: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fi.png",
});
});
});
describe("eslint raw-HTML sink rule", () => {
const linter = new Linter({ configType: "flat" });
const lint = (code: string) =>
linter.verify(code, [
{
languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } },
rules: noRawHtmlSinks.rules,
},
]);
it.each([
"const x = <div dangerouslySetInnerHTML={{ __html: post.html }} />;",
"React.createElement('div', { dangerouslySetInnerHTML: { __html: s } });",
"el.innerHTML = s;",
"el.outerHTML = s;",
"el.insertAdjacentHTML('beforeend', s);",
"document.write(s);",
"new Function(s)();",
"eval(s);",
"new DOMParser().parseFromString(s, 'text/html');",
])("flags %s", (code) => {
expect(lint(code).map((m) => m.ruleId)).toContain("no-restricted-syntax");
});
it("allows text rendering", () => {
expect(lint("const x = <div>{post.text}</div>; el.textContent = s;")).toEqual([]);
});
});

View file

@ -0,0 +1,44 @@
import type { RichTextDocument } from "./ast.js";
import { DocBuilder, type ParseOptions } from "./builder.js";
// Single-pass, backtracking-safe token scan: URL | @mention | #hashtag.
const TOKENS =
/(https?:\/\/[^\s<>"'`\u0000-\u001F]{1,2048})|(?<![\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{1,64}(?:@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63}){0,8})?)|(?<![\p{L}\p{N}_#&])#([\p{L}\p{N}\p{M}_]{1,100})/gu;
const TRAILING_PUNCT = /[.,;:!?)\]}'"»”’]+$/;
function lineToNodes(builder: DocBuilder, line: string): void {
let last = 0;
for (const match of line.matchAll(TOKENS)) {
const [whole, url, handle, tag] = match;
let consumed = whole;
if (match.index > last) builder.text(line.slice(last, match.index));
if (url !== undefined) {
const trimmed = url.replace(TRAILING_PUNCT, "");
consumed = trimmed;
builder.link(trimmed, builder.inlineText(trimmed.replace(/^https?:\/\//i, "")));
} else if (handle !== undefined) {
const clean = handle.replace(/[.-]+$/, "");
consumed = "@" + clean;
builder.mention(clean);
} else if (tag !== undefined) {
builder.hashtag(tag);
}
last = match.index + consumed.length;
}
if (last < line.length) builder.text(line.slice(last));
}
/** Plain provider text (Bluesky, Telegram, bios, …): newlines, URLs, mentions, hashtags. */
export function parsePlainText(input: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder(options);
if (typeof input !== "string") return builder.finish();
const text = builder.clip(input).replace(/\r\n?/g, "\n");
for (const paragraph of text.split(/\n{2,}/)) {
paragraph.split("\n").forEach((line, i) => {
if (i > 0) builder.lineBreak();
lineToNodes(builder, line);
});
builder.endParagraph();
}
return builder.finish();
}

View file

@ -0,0 +1,135 @@
import type { ReactNode } from "react";
import type { EmojiNode, InlineNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js";
import { neutralizeDisplayName, hasMixedScriptConfusables } from "./text.js";
import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, type MediaProxyPolicy } from "./url.js";
import { coerceRichTextDocument, parseLinkPreview } from "./validate.js";
/*
* The single reviewed renderer for provider content (proposal §4.5). Every
* provider string reaches the DOM as a React text node; the only attributes
* set from provider data are re-validated http(s) `href`s and media-proxy
* `src`s. No dangerouslySetInnerHTML, no style, no event handlers.
*/
export const EXTERNAL_LINK_REL = "noopener noreferrer nofollow ugc";
type CommonProps = { readonly className?: string; readonly mediaProxy?: MediaProxyPolicy };
export function SafeLink(props: { readonly href: string; readonly children?: ReactNode; readonly className?: string }): ReactNode {
const safe = parseSafeHttpUrl(props.href);
if (!safe) return <span className={props.className}>{props.children}</span>;
return (
<a href={safe.href} rel={EXTERNAL_LINK_REL} target="_blank" referrerPolicy="no-referrer" className={props.className}>
{props.children ?? safe.host}
</a>
);
}
function Emoji({ node, mediaProxy }: { node: EmojiNode; mediaProxy: MediaProxyPolicy }): ReactNode {
const label = `:${node.shortcode}:`;
if (!isProxiedMediaUrl(node.src, mediaProxy)) return label;
return (
<img
src={node.src}
alt={label}
title={label}
className="sc-emoji"
draggable={false}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
);
}
function leaf(node: TextNode | EmojiNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode {
return node.type === "text" ? node.text : <Emoji key={key} node={node} mediaProxy={mediaProxy} />;
}
function labelStartsWithHost(children: readonly (TextNode | EmojiNode)[], host: string): boolean {
const label = children
.map((c) => (c.type === "text" ? c.text : ""))
.join("")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "");
const h = host.toLowerCase().replace(/^www\./, "");
return label === h || label.startsWith(h + "/") || label.startsWith(h + "?") || label.startsWith(h + "#") || label.startsWith(h + ":");
}
function inline(node: InlineNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode {
switch (node.type) {
case "text":
return node.text;
case "linebreak":
return <br key={key} />;
case "emoji":
return <Emoji key={key} node={node} mediaProxy={mediaProxy} />;
case "link":
return (
<span key={key} className="sc-link">
<SafeLink href={node.href}>{node.children.map((c, i) => leaf(c, i, mediaProxy))}</SafeLink>
{/* The real destination host is always visible when the label could mislead. */}
{labelStartsWithHost(node.children, node.host) ? null : <span className="sc-link-host"> ({node.host})</span>}
</span>
);
case "mention":
return node.href ? (
<SafeLink key={key} href={node.href} className="sc-mention">{`@${node.handle}`}</SafeLink>
) : (
<span key={key} className="sc-mention">{`@${node.handle}`}</span>
);
case "hashtag":
return node.href ? (
<SafeLink key={key} href={node.href} className="sc-hashtag">{`#${node.tag}`}</SafeLink>
) : (
<span key={key} className="sc-hashtag">{`#${node.tag}`}</span>
);
default:
return null;
}
}
/** Renders a rich-text document. `doc` may be untrusted JSON; it is re-validated first. */
export function RichText(props: CommonProps & { readonly doc: RichTextDocument | unknown }): ReactNode {
const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY;
const doc = coerceRichTextDocument(props.doc, { mediaProxy });
return (
<div className={props.className} dir="auto" data-truncated={doc.truncated ? "true" : undefined}>
{doc.children.map((p, i) => (
<p key={i} dir="auto">
{p.children.map((n, j) => inline(n, j, mediaProxy))}
</p>
))}
</div>
);
}
/** Link-preview card. `preview` may be raw provider card data or a built `LinkPreview`. */
export function LinkPreviewCard(props: CommonProps & { readonly preview: LinkPreview | unknown }): ReactNode {
const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY;
const preview = parseLinkPreview(props.preview, mediaProxy);
if (!preview) return null;
return (
<a href={preview.href} rel={EXTERNAL_LINK_REL} target="_blank" referrerPolicy="no-referrer" className={props.className}>
{preview.image && isProxiedMediaUrl(preview.image, mediaProxy) ? (
<img src={preview.image} alt="" loading="lazy" decoding="async" referrerPolicy="no-referrer" />
) : null}
{preview.siteName ? <span className="sc-preview-site" dir="auto">{preview.siteName}</span> : null}
{preview.title ? <strong className="sc-preview-title" dir="auto">{preview.title}</strong> : null}
{preview.description ? <span className="sc-preview-description" dir="auto">{preview.description}</span> : null}
<span className="sc-preview-host">{preview.host}</span>
</a>
);
}
/** Display names / usernames: bidi + zero-width neutralised, isolated, confusables flagged. */
export function DisplayName(props: { readonly name: unknown; readonly maxLength?: number; readonly className?: string }): ReactNode {
const name = typeof props.name === "string" ? neutralizeDisplayName(props.name, props.maxLength) : "";
return (
<bdi className={props.className} data-confusable={hasMixedScriptConfusables(name) ? "true" : undefined}>
{name}
</bdi>
);
}

View file

@ -0,0 +1,57 @@
/**
* Bidi embedding/override/isolate controls (U+202A–U+202E, U+2066–U+2069) can
* visually reorder text ("evil" + U+202E + "txt.exe"); they are always removed.
*/
const BIDI_CONTROLS = /[\u202A-\u202E\u2066-\u2069]/g;
/** Marks and zero-width characters used for spoofing look-alike names. */
const INVISIBLES = /[\u200B-\u200F\u061C\u180E\u2060-\u2064\uFEFF\u034F\u115F\u1160\u3164\uFFA0]/g;
/** C0/C1 controls except tab and newline; lone surrogates. */
const CONTROLS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
/** Normalises body text: strips controls and bidi overrides, keeps ZWJ (emoji sequences). */
export function neutralizeText(input: string): string {
return input.replace(CONTROLS, "").replace(BIDI_CONTROLS, "").replace(/\r\n?/g, "\n");
}
/**
* Display names, usernames, channel names: single line, no bidi or zero-width
* characters at all, whitespace collapsed, length-capped.
*/
export function neutralizeDisplayName(input: string, maxLength = 100): string {
const cleaned = input
.normalize("NFC")
.replace(CONTROLS, "")
.replace(BIDI_CONTROLS, "")
.replace(INVISIBLES, "")
.replace(/\s+/g, " ")
.trim();
return truncateCodePoints(cleaned, maxLength);
}
export function truncateCodePoints(input: string, max: number): string {
if (input.length <= max) return input;
const chars = Array.from(input);
return chars.length <= max ? input : chars.slice(0, max).join("") + "…";
}
const SCRIPT_RUNS: readonly [string, RegExp][] = [
["Latin", /\p{Script=Latin}/u],
["Cyrillic", /\p{Script=Cyrillic}/u],
["Greek", /\p{Script=Greek}/u],
["Armenian", /\p{Script=Armenian}/u],
["Cherokee", /\p{Script=Cherokee}/u],
];
/**
* Homoglyph heuristic: true when a single word mixes Latin with a script that
* has Latin look-alikes ("pаypal" with Cyrillic "а"). The UI should flag such
* names rather than trust them; it is a warning, not a sanitiser.
*/
export function hasMixedScriptConfusables(input: string): boolean {
for (const word of input.normalize("NFKC").split(/[\s\p{P}]+/u)) {
let seen = 0;
for (const [, re] of SCRIPT_RUNS) if (re.test(word)) seen++;
if (seen > 1) return true;
}
return false;
}

View file

@ -0,0 +1,69 @@
import { DEFAULT_LIMITS } from "./limits.js";
export type SafeUrl = { readonly href: string; readonly host: string };
const ABSOLUTE_HTTP = /^https?:\/\/[^/\\]/i;
/**
* Accepts only absolute, credential-free http(s) URLs. Everything else —
* `javascript:`, `data:`, `vbscript:`, `file:`, protocol-relative, relative,
* backslash tricks, embedded controls/whitespace — returns null so the caller
* renders the text inertly.
*/
export function parseSafeHttpUrl(raw: unknown, maxLength = DEFAULT_LIMITS.maxUrlLength): SafeUrl | null {
if (typeof raw !== "string") return null;
const input = raw.trim();
if (input.length === 0 || input.length > maxLength) return null;
// Browsers strip tab/newline inside URLs ("java\tscript:"); refuse instead of guessing.
if (/[\u0000-\u0020\u007F-\u00A0\u1680\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\uFEFF]/.test(input)) return null;
if (!ABSOLUTE_HTTP.test(input)) return null;
let url: URL;
try {
url = new URL(input);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username !== "" || url.password !== "") return null;
if (url.hostname === "") return null;
if (url.href.length > maxLength) return null;
// `URL` serialises IDN hosts as punycode (xn--…), which exposes homoglyph domains.
return { href: url.href, host: url.host };
}
export type MediaProxyPolicy = {
/** Same-origin path of the app's media proxy, e.g. `/api/media-proxy`. */
readonly proxyPath: string;
};
export const DEFAULT_MEDIA_PROXY: MediaProxyPolicy = { proxyPath: "/api/media-proxy" };
function assertProxyPath(path: string): void {
if (!/^\/[A-Za-z0-9._~\-/]*$/.test(path) || path.startsWith("//") || path.includes("/../") || path.endsWith("/..")) {
throw new Error(`safe-content: media proxy path must be a same-origin absolute path, got ${JSON.stringify(path)}`);
}
}
/** Rewrites a remote media URL into a media-proxy URL; null if the origin URL is unsafe. */
export function toProxiedMediaUrl(raw: unknown, policy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): string | null {
assertProxyPath(policy.proxyPath);
const safe = parseSafeHttpUrl(raw);
if (!safe) return null;
return `${policy.proxyPath}?url=${encodeURIComponent(safe.href)}`;
}
/** True when `src` is exactly a media-proxy URL produced by `toProxiedMediaUrl`. */
export function isProxiedMediaUrl(src: unknown, policy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): src is string {
if (typeof src !== "string") return false;
assertProxyPath(policy.proxyPath);
const prefix = `${policy.proxyPath}?url=`;
if (!src.startsWith(prefix)) return false;
let decoded: string;
try {
decoded = decodeURIComponent(src.slice(prefix.length));
} catch {
return false;
}
// Canonical form only: re-deriving the proxy URL must reproduce `src` exactly.
return toProxiedMediaUrl(decoded, policy) === src;
}

View file

@ -0,0 +1,107 @@
import type { EmojiNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js";
import { DocBuilder, normalizeHandle, normalizeTag, type ParseOptions } from "./builder.js";
import { decodeEntities } from "./entities.js";
import { LINK_PREVIEW_LIMITS } from "./limits.js";
import { neutralizeText, truncateCodePoints } from "./text.js";
import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js";
type Unknown = Record<string, unknown>;
const isObj = (v: unknown): v is Unknown => typeof v === "object" && v !== null && !Array.isArray(v);
const arr = (v: unknown): readonly unknown[] => (Array.isArray(v) ? v : []);
/**
* Rebuilds a document from untrusted JSON (DB row, API payload, websocket
* frame). Unknown node types, bad URLs and non-proxied media are dropped;
* text is re-neutralised; limits are re-applied. The renderer always calls
* this, so a forged AST is as inert as a parsed one.
*/
export function coerceRichTextDocument(value: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder({ ...options, emojis: [] });
const policy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY;
if (!isObj(value) || value.type !== "doc") return builder.finish();
if (value.truncated === true) builder.truncated = true;
const leaf = (n: unknown): (TextNode | EmojiNode)[] => {
if (!isObj(n)) return [];
if (n.type === "text" && typeof n.text === "string") return builder.inlineText(n.text);
if (n.type === "emoji" && typeof n.shortcode === "string" && /^[A-Za-z0-9_+-]{1,64}$/.test(n.shortcode)) {
return isProxiedMediaUrl(n.src, policy)
? [{ type: "emoji", shortcode: n.shortcode, src: n.src }]
: [{ type: "text", text: `:${n.shortcode}:` }];
}
return [];
};
const capped = (items: unknown): readonly unknown[] => {
const all = arr(items);
if (all.length <= builder.limits.maxNodes) return all;
builder.truncated = true;
return all.slice(0, builder.limits.maxNodes);
};
for (const p of capped(value.children)) {
if (!isObj(p) || p.type !== "paragraph") continue;
for (const n of capped(p.children)) {
if (!isObj(n)) continue;
switch (n.type) {
case "text":
if (typeof n.text === "string") builder.text(n.text);
break;
case "emoji":
for (const e of leaf(n)) builder.pushLeaf(e);
break;
case "linebreak":
builder.lineBreak();
break;
case "link":
builder.link(typeof n.href === "string" ? n.href : "", arr(n.children).slice(0, 64).flatMap(leaf));
break;
case "mention":
if (typeof n.handle === "string" && normalizeHandle(n.handle)) builder.mention(n.handle, typeof n.href === "string" ? n.href : undefined);
break;
case "hashtag":
if (typeof n.tag === "string" && normalizeTag(n.tag)) builder.hashtag(n.tag, typeof n.href === "string" ? n.href : undefined);
break;
}
}
builder.endParagraph();
}
return builder.finish();
}
export type RawLinkPreview = {
readonly url?: unknown;
readonly title?: unknown;
readonly description?: unknown;
readonly siteName?: unknown;
readonly image?: unknown;
};
function previewText(value: unknown, max: number): string | undefined {
if (typeof value !== "string") return undefined;
// OpenGraph fields are text: entities are decoded, tags are NOT interpreted.
const text = neutralizeText(decodeEntities(value.slice(0, max * 4))).replace(/\s+/g, " ").trim();
return text === "" ? undefined : truncateCodePoints(text, max);
}
/**
* Link-preview card data (OpenGraph/oEmbed/Mastodon `card`). Returns null when
* the target is not a safe http(s) URL. The image is rewritten to the proxy
* or dropped. Accepts either raw provider data or a previously built preview.
*/
export function parseLinkPreview(raw: unknown, mediaProxy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): LinkPreview | null {
if (!isObj(raw)) return null;
const safe = parseSafeHttpUrl(raw.type === "linkPreview" ? raw.href : raw.url);
if (!safe) return null;
const image = isProxiedMediaUrl(raw.image, mediaProxy) ? raw.image : toProxiedMediaUrl(raw.image, mediaProxy);
const title = previewText(raw.title, LINK_PREVIEW_LIMITS.title);
const description = previewText(raw.description, LINK_PREVIEW_LIMITS.description);
const siteName = previewText(raw.siteName, LINK_PREVIEW_LIMITS.siteName);
return {
type: "linkPreview",
href: safe.href,
host: safe.host,
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(siteName !== undefined && { siteName }),
...(image !== null && { image }),
};
}