From 40e4e5340023733087d2971c5dcc295a77a98108 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 12:40:43 +0700 Subject: [PATCH] Hardening: https-only URLs, route-level crash resilience, cleaner errors (1.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AppRouter.tsx | 14 +++++--- src/components/ErrorBoundary.tsx | 51 +++++++++----------------- src/components/RouteErrorBoundary.tsx | 52 +++++++++++++++++++++++++++ src/lib/sanitizeUrl.ts | 43 +++++++++++++++++----- src/test/ErrorBoundary.test.tsx | 6 ++-- 5 files changed, 118 insertions(+), 48 deletions(-) create mode 100644 src/components/RouteErrorBoundary.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 069e62b..b96f09e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from "react"; import { HashRouter, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import { DeepLinkHandler } from "./components/DeepLinkHandler"; +import { RouteErrorBoundary } from "./components/RouteErrorBoundary"; // Eagerly load the map — it's now the homepage import MapPage from "./pages/MapPage"; @@ -83,8 +84,12 @@ export function AppRouter() { - }> - + {/* Per-route boundary: a crash in one page shows a recoverable message + instead of blanking the whole app. Resets automatically on navigate + (keyed by pathname inside RouteErrorBoundary). */} + + }> + {/* Map is the homepage */} } /> {/* /map redirects to / */} @@ -163,8 +168,9 @@ export function AppRouter() { } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> - - + + + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index c19d500..f8a3778 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -31,6 +31,9 @@ export class ErrorBoundary extends Component -
-
-

- Something went wrong +
+
+ 🛹 +
+
+

+ Slammed

-

- 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.

-
-
- - Error details - -
-
- Message: -

- {this.state.error?.message} -

-
- {this.state.error?.stack && ( -
- Stack trace: -
-                        {this.state.error.stack}
-                      
-
- )} -
-
-
- -
+
diff --git a/src/components/RouteErrorBoundary.tsx b/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..b145630 --- /dev/null +++ b/src/components/RouteErrorBoundary.tsx @@ -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 ( + +
+
+ 🛹 +
+
+

+ This page slammed +

+

+ Something on this screen tripped up. Your account and data are safe. + Head back and try again. +

+
+ +
+
+ } + > + {children} + + ); +} + +export default RouteErrorBoundary; diff --git a/src/lib/sanitizeUrl.ts b/src/lib/sanitizeUrl.ts index 3d28d3b..10ac5a8 100644 --- a/src/lib/sanitizeUrl.ts +++ b/src/lib/sanitizeUrl.ts @@ -2,9 +2,13 @@ * Sanitizes a user-supplied URL to prevent phishing and script injection. * * Rules: - * - Only allows https:// and http:// schemes - * - Blocks javascript:, data:, vbscript:, and all other schemes - * - Returns null for invalid / unsafe URLs (caller should hide the link) + * - 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. @@ -17,17 +21,40 @@ export function sanitizeUrl(url: string | undefined | null): string | null { try { const parsed = new URL(trimmed); - // Only allow safe schemes - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return null; + + if (parsed.protocol === 'https:') { + 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 { - // 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; } } +/** + * 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") */ diff --git a/src/test/ErrorBoundary.test.tsx b/src/test/ErrorBoundary.test.tsx index 3f49427..7e3634f 100644 --- a/src/test/ErrorBoundary.test.tsx +++ b/src/test/ErrorBoundary.test.tsx @@ -33,8 +33,10 @@ describe('ErrorBoundary', () => { ); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - expect(screen.getByText('An unexpected error occurred. The error has been reported.')).toBeInTheDocument(); + expect(screen.getByText('Slammed')).toBeInTheDocument(); + expect( + screen.getByText(/Something tripped up this screen/i) + ).toBeInTheDocument(); consoleSpy.mockRestore(); });