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:
parent
8d7b4f914b
commit
40e4e53400
5 changed files with 118 additions and 48 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
52
src/components/RouteErrorBoundary.tsx
Normal file
52
src/components/RouteErrorBoundary.tsx
Normal 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;
|
||||||
|
|
@ -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")
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue