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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
69
packages/safe-content/src/url.ts
Normal file
69
packages/safe-content/src/url.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue