← back to Stayclaim
src/lib/url-guards.ts
48 lines
/**
* url-guards — defense-in-depth scheme allowlists for DB-sourced URLs.
*
* Many fields in stayclaim come from public ingest pipelines (LADBS, Socrata,
* IA, Wikimedia, Openverse, etc.). A poisoned `javascript:` / `data:` /
* `vbscript:` URL would render as a clickable XSS sink. Always validate the
* scheme before passing user/DB-sourced URLs to <a href>, <img src>, <video src>,
* <iframe src>, etc.
*
* Centralized here so the same allowlist is enforced everywhere — and so we
* can tighten it (CSP, host allowlist, etc.) in one place if needed.
*/
/**
* Returns the URL if it has an http(s) scheme, otherwise null.
* Use as: `<a href={safeHttpUrl(u) ?? '#'}>` or gate the whole element on the
* return value.
*/
export function safeHttpUrl(u: string | null | undefined): string | null {
return u && /^https?:\/\//i.test(u) ? u : null;
}
/** Boolean shorthand: `isSafeHttp(url) && <img src={url} />` */
export function isSafeHttp(u: string | null | undefined): u is string {
return !!u && /^https?:\/\//i.test(u);
}
/**
* Same allowlist as safeHttpUrl, but takes `unknown` (DB-row reads where the
* column type can't be narrowed at the call site — admin browsers, JSON-typed
* fields, `Record<string, unknown>` rows). Uses `new URL()` so it rejects
* malformed strings like "http://" with no host that the bare regex form
* would otherwise return.
*/
export function safeHttpUrlUnknown(u: unknown): string | null {
if (typeof u !== 'string' || !u) return null;
const t = u.trim();
if (!/^https?:\/\//i.test(t)) return null;
try {
const parsed = new URL(t);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (!parsed.host) return null;
return parsed.toString();
} catch {
return null;
}
}