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/.
69 lines
2.9 KiB
TypeScript
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;
|
|
}
|