SkateSpots/src/lib/sanitizeUrl.ts
shakespeare.diy 40e4e53400 Hardening: https-only URLs, route-level crash resilience, cleaner errors (1.4.3)
Security & reliability audit follow-up. The app was already well-hardened
(strict CSP synced across index.html/_headers/netlify.toml, careful
textContent/escapeHtml in the Leaflet DOM, broad sanitizeUrl usage,
consistent author-filtering on trust-sensitive queries). This closes the
remaining gaps:

- sanitizeUrl is now HTTPS-only: bare http:// links are upgraded to
  https:// (not silently dropped); every other scheme (javascript:,
  data:, file:, relative) is rejected. Removes a downgrade/mixed-content/
  IP-leak vector and matches the native HTTPS-only WebView. Added a
  sanitizeCssString() allowlist helper for future CSS interpolation.

- Route-level ErrorBoundary (RouteErrorBoundary) wraps the routed pages,
  keyed by pathname: a crash in one page now shows a recoverable
  "this page slammed → go back" message while the header/nav/rest of the
  app keep working, instead of the single top-level boundary blanking the
  ENTIRE app. Resets automatically on navigation.

- ErrorBoundary fallback no longer leaks stack traces to users (full
  detail still logged to console for us); redesigned on-brand ("Slammed",
  neon, reassures that account + data are safe). Updated its test.

Author-filtering spot-check across admin/newsletter/featured/crew/stash
paths found no gaps — all trust-sensitive queries correctly constrain
authors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 12:40:43 +07:00

89 lines
2.9 KiB
TypeScript

/**
* 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, '');
}
/**
* 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;
}
export function sanitizeEmail(value: string | undefined | null): string | null {
if (!value) return null;
const trimmed = value.trim();
// Basic email pattern
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
return `mailto:${trimmed}`;
}
return null;
}