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>
This commit is contained in:
shakespeare.diy 2026-09-09 12:40:43 +07:00
parent 8d7b4f914b
commit 40e4e53400
5 changed files with 118 additions and 48 deletions

View file

@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import { HashRouter, Route, Routes } from "react-router-dom"; import { HashRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop"; import { ScrollToTop } from "./components/ScrollToTop";
import { DeepLinkHandler } from "./components/DeepLinkHandler"; import { DeepLinkHandler } from "./components/DeepLinkHandler";
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
// Eagerly load the map — it's now the homepage // Eagerly load the map — it's now the homepage
import MapPage from "./pages/MapPage"; import MapPage from "./pages/MapPage";
@ -83,8 +84,12 @@ export function AppRouter() {
<HashRouter> <HashRouter>
<DeepLinkHandler /> <DeepLinkHandler />
<ScrollToTop /> <ScrollToTop />
<Suspense fallback={<PageLoader />}> {/* Per-route boundary: a crash in one page shows a recoverable message
<Routes> instead of blanking the whole app. Resets automatically on navigate
(keyed by pathname inside RouteErrorBoundary). */}
<RouteErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Map is the homepage */} {/* Map is the homepage */}
<Route path="/" element={<MapPage />} /> <Route path="/" element={<MapPage />} />
{/* /map redirects to / */} {/* /map redirects to / */}
@ -163,8 +168,9 @@ export function AppRouter() {
<Route path="/:nip19" element={<NIP19Page />} /> <Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Suspense> </Suspense>
</RouteErrorBoundary>
</HashRouter> </HashRouter>
); );
} }

View file

@ -31,6 +31,9 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo) { componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Full detail (message + component stack) goes to the console for us; it is
// deliberately NOT surfaced to users — stack traces can leak internal
// structure and are meaningless noise to a skater.
console.error('Error caught by ErrorBoundary:', error, errorInfo); console.error('Error caught by ErrorBoundary:', error, errorInfo);
this.setState({ this.setState({
@ -55,52 +58,32 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return ( return (
<div className="min-h-screen bg-black text-white flex items-center justify-center p-4"> <div className="min-h-screen bg-black text-white flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-4"> <div className="max-w-md w-full space-y-6 text-center">
<div className="text-center"> <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-4xl">
<h2 className="text-2xl font-bold text-foreground mb-2"> 🛹
Something went wrong </div>
<div className="space-y-2">
<h2 className="text-2xl font-black uppercase text-[#ff0040]">
Slammed
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-gray-400">
An unexpected error occurred. The error has been reported. Something tripped up this screen. Your account and data are safe —
let's get you rolling again.
</p> </p>
</div> </div>
<div className="bg-muted p-4 rounded-lg"> <div className="flex flex-col sm:flex-row gap-2">
<details className="text-sm">
<summary className="cursor-pointer font-medium text-foreground">
Error details
</summary>
<div className="mt-2 space-y-2">
<div>
<strong className="text-foreground">Message:</strong>
<p className="text-muted-foreground mt-1">
{this.state.error?.message}
</p>
</div>
{this.state.error?.stack && (
<div>
<strong className="text-foreground">Stack trace:</strong>
<pre className="text-xs text-muted-foreground mt-1 overflow-auto max-h-32">
{this.state.error.stack}
</pre>
</div>
)}
</div>
</details>
</div>
<div className="flex gap-2">
<button <button
onClick={this.handleReset} onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors" className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
> >
Try again Try again
</button> </button>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors" className="flex-1 px-4 py-3 rounded-full font-bold border border-white/20 text-white hover:bg-white/5 transition-colors"
> >
Reload page Reload app
</button> </button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,52 @@
import { useLocation } from 'react-router-dom';
import { ErrorBoundary } from '@/components/ErrorBoundary';
/**
* Per-route error boundary.
*
* The app has a single top-level ErrorBoundary (in main.tsx) as a last resort,
* but on its own that means ANY component throwing blanks the ENTIRE app. This
* wrapper adds a second, inner boundary around the routed page content so a
* crash in one page shows a recoverable message while the app keeps running.
*
* It is keyed by the current pathname: navigating to a different route remounts
* the boundary and clears the error automatically, so a broken page never
* "sticks" once the user moves away.
*/
export function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<ErrorBoundary
key={location.pathname}
fallback={
<div className="min-h-[60vh] flex items-center justify-center p-6">
<div className="max-w-sm w-full text-center space-y-5">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-3xl">
🛹
</div>
<div className="space-y-1.5">
<h2 className="text-xl font-black uppercase text-[#ff0040]">
This page slammed
</h2>
<p className="text-sm text-gray-400">
Something on this screen tripped up. Your account and data are safe.
Head back and try again.
</p>
</div>
<button
onClick={() => window.history.back()}
className="w-full px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
>
Go back
</button>
</div>
</div>
}
>
{children}
</ErrorBoundary>
);
}
export default RouteErrorBoundary;

View file

@ -2,9 +2,13 @@
* Sanitizes a user-supplied URL to prevent phishing and script injection. * Sanitizes a user-supplied URL to prevent phishing and script injection.
* *
* Rules: * Rules:
* - Only allows https:// and http:// schemes * - Only emits https:// URLs. `javascript:`, `data:`, `vbscript:`, `file:`,
* - Blocks javascript:, data:, vbscript:, and all other schemes * relative paths, and every other scheme are rejected (returns null).
* - Returns null for invalid / unsafe URLs (caller should hide the link) * - 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, * VPN/Tor users are unaffected — this validates the URL scheme & structure,
* not the user's network origin. * not the user's network origin.
@ -17,17 +21,40 @@ export function sanitizeUrl(url: string | undefined | null): string | null {
try { try {
const parsed = new URL(trimmed); const parsed = new URL(trimmed);
// Only allow safe schemes
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { if (parsed.protocol === 'https:') {
return null; return parsed.href;
} }
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 { } catch {
// Not a valid URL // Not a valid absolute URL (also rejects relative paths — intentional,
// since untrusted relative URLs can trigger same-origin requests).
return null; 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") * Extracts just the hostname for display (e.g. "Opens: example.com")
*/ */

View file

@ -33,8 +33,10 @@ describe('ErrorBoundary', () => {
</ErrorBoundary> </ErrorBoundary>
); );
expect(screen.getByText('Something went wrong')).toBeInTheDocument(); expect(screen.getByText('Slammed')).toBeInTheDocument();
expect(screen.getByText('An unexpected error occurred. The error has been reported.')).toBeInTheDocument(); expect(
screen.getByText(/Something tripped up this screen/i)
).toBeInTheDocument();
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });