/** * Sanitizes a user-supplied URL to prevent phishing and script injection. * * Rules: * - Only emits https:// URLs. `javascript:`, `data:`, `vbscript:`, `file:`, * relative paths, and every other scheme are rejected (returns null). * - Bare `http://` links are UPGRADED to `https://` rather than dropped, so we * don't silently lose links that work fine over TLS. Plain `http` is a * downgrade / mixed-content / IP-leak vector and also fails outright inside * the native (Capacitor) HTTPS-only WebView, so we never emit it. * - Returns null for invalid / unsafe URLs (caller should hide the link). * * VPN/Tor users are unaffected — this validates the URL scheme & structure, * not the user's network origin. */ export function sanitizeUrl(url: string | undefined | null): string | null { if (!url) return null; const trimmed = url.trim(); if (!trimmed) return null; try { const parsed = new URL(trimmed); if (parsed.protocol === 'https:') { return parsed.href; } // Upgrade http → https instead of dropping the link outright. if (parsed.protocol === 'http:') { parsed.protocol = 'https:'; return parsed.href; } // Everything else (javascript:, data:, file:, etc.) is unsafe. return null; } catch { // Not a valid absolute URL (also rejects relative paths — intentional, // since untrusted relative URLs can trigger same-origin requests). return null; } } /** * Allowlists safe characters for interpolating an untrusted string into a CSS * value (font-family, animation-name, etc.). Keeps Unicode letters/numbers, * spaces, hyphens, underscores, apostrophes and periods; strips everything * that could break out of the CSS string context (`"`, `)`, `}`, `;`, `\`). * * URLs in `url(...)` must use `sanitizeUrl()` instead — the URL constructor * percent-encodes the dangerous characters and rejects non-http(s) schemes. */ export function sanitizeCssString(value: string | undefined | null): string { if (!value) return ''; return value.replace(/[^\p{L}\p{N} _\-'.]/gu, ''); } /** * Normalize a user-typed URL *before storing it* so bare domains still work. * * Users routinely type `example.com` or `instagram.com/me` with no scheme. * `new URL()` rejects those, so `sanitizeUrl()` would hide the link and the * user would think they'd provided it. Prefix `https://` for host-like values * only (must contain a dot, no spaces/colons) so handles like `@skater` and * already-schemed values (`https://…`, `mailto:…`) pass through untouched. * * Returns '' for empty input. Stored values are still sanitized again at render. */ export function normalizeUrlInput(value: string): string { const trimmed = value.trim(); if (!trimmed) return ''; // Already has a scheme (https:, http:, mailto:, …) — leave it alone. if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed; // Only invent a scheme for host-like values: domain.tld optionally + path. if (!/^[\w-]+(\.[\w-]+)+(\/\S*)?$/i.test(trimmed)) return trimmed; return `https://${trimmed}`; } /** * Extracts just the hostname for display (e.g. "Opens: example.com") */ export function urlDomain(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } } /** * Sanitizes a tel: or mailto: href. * Only allows the specific scheme, strips anything else. */ export function sanitizeTel(value: string | undefined | null): string | null { if (!value) return null; const trimmed = value.trim(); // Allow digits, +, -, spaces, parentheses only const digits = trimmed.replace(/[^0-9+\-\s().]/g, ''); return digits.length >= 5 ? `tel:${digits}` : null; } /** Basic email pattern: non-empty local@domain.tld. */ export function isValidEmail(value: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); } export function sanitizeEmail(value: string | undefined | null): string | null { if (!value) return null; const trimmed = value.trim(); if (isValidEmail(trimmed)) { return `mailto:${trimmed}`; } return null; }