T-HUB/packages/safe-content/src/url.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

69 lines
2.9 KiB
TypeScript

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;
}