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