Skip to content

Commit 550293f

Browse files
authored
perf: lazy-loads route components with ErrorBoundary and chunk prefetch (#40)
* perf: lazy-loads route components with ErrorBoundary and chunk prefetch * fix(csp): adds blob: to img-src for internal blob image URLs * feat(csp): adds report-uri to send CSP violations to Sentry * fix: addresses PR #40 review findings - adds Suspense boundary wrapping Router for lazy chunk loading states - adds /api/csp-report worker endpoint that scrubs OAuth params before forwarding CSP violation reports to Sentry (replaces direct report-uri) - adds Reporting-Endpoints header enabling modern report-to directive - removes unnecessary blob: from CSP img-src - extracts ErrorBoundary fallback to named handleRouteError function - adds .catch() with console.warn to both prefetch paths - adds [app] to Sentry allowed console breadcrumb prefixes - extracts getOrCacheDsn helper to deduplicate DSN cache logic - adds parseSentryDsn publicKey validation - caps CSP report batch fan-out to 20 to prevent amplification - scrubs referrer field in CSP reports (OAuth param leak vector) - adds 18 tests covering CSP report endpoint, ErrorBoundary for all lazy routes, prefetch scheduling, and sentry prefix
1 parent fbc6b74 commit 550293f

File tree

9 files changed

+628
-29
lines changed

9 files changed

+628
-29
lines changed

public/_headers

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
2-
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests
2+
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint
3+
Reporting-Endpoints: csp-endpoint="/api/csp-report"
34
X-Content-Type-Options: nosniff
45
Referrer-Policy: strict-origin-when-cross-origin
56
Permissions-Policy: geolocation=(), microphone=(), camera=()

src/app/App.tsx

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
1-
import { createSignal, createEffect, onMount, Show, type JSX } from "solid-js";
1+
import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js";
22
import { Router, Route, Navigate, useNavigate } from "@solidjs/router";
3-
import { isAuthenticated, validateToken } from "./stores/auth";
3+
import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth";
44
import { config, initConfigPersistence, resolveTheme } from "./stores/config";
55
import { initViewPersistence } from "./stores/view";
66
import { evictStaleEntries } from "./stores/cache";
77
import { initClientWatcher } from "./services/github";
88
import LoginPage from "./pages/LoginPage";
99
import OAuthCallback from "./pages/OAuthCallback";
10-
import DashboardPage from "./components/dashboard/DashboardPage";
11-
import OnboardingWizard from "./components/onboarding/OnboardingWizard";
12-
import SettingsPage from "./components/settings/SettingsPage";
1310
import PrivacyPage from "./pages/PrivacyPage";
1411

12+
const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage"));
13+
const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard"));
14+
const SettingsPage = lazy(() => import("./components/settings/SettingsPage"));
15+
16+
function handleRouteError(err: unknown) {
17+
console.error("[app] Route render failed:", err);
18+
return <ChunkErrorFallback />;
19+
}
20+
21+
function ChunkErrorFallback() {
22+
return (
23+
<div class="min-h-screen flex items-center justify-center bg-base-200">
24+
<div class="card bg-base-100 shadow-md p-8 flex flex-col items-center gap-4 max-w-sm">
25+
<p class="text-error font-medium">Failed to load page</p>
26+
<p class="text-sm text-base-content/60 text-center">
27+
A new version may have been deployed. Reloading should fix this.
28+
</p>
29+
<button
30+
type="button"
31+
class="btn btn-neutral"
32+
onClick={() => window.location.reload()}
33+
>
34+
Reload page
35+
</button>
36+
</div>
37+
</div>
38+
);
39+
}
40+
1541
// Auth guard: redirects unauthenticated users to /login.
1642
// On page load, validates the localStorage token with GitHub API.
1743
function AuthGuard(props: { children: JSX.Element }) {
@@ -138,17 +164,33 @@ export default function App() {
138164
evictStaleEntries(24 * 60 * 60 * 1000).catch(() => {
139165
// Non-fatal — stale eviction failure is acceptable
140166
});
167+
168+
// Preload dashboard chunk in parallel with token validation to avoid
169+
// a sequential waterfall (validateToken → chunk fetch)
170+
if (localStorage.getItem?.(AUTH_STORAGE_KEY)) {
171+
import("./components/dashboard/DashboardPage").catch(() => {
172+
console.warn("[app] Dashboard chunk preload failed");
173+
});
174+
}
141175
});
142176

143177
return (
144-
<Router>
145-
<Route path="/" component={RootRedirect} />
146-
<Route path="/login" component={LoginPage} />
147-
<Route path="/oauth/callback" component={OAuthCallback} />
148-
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
149-
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
150-
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
151-
<Route path="/privacy" component={PrivacyPage} />
152-
</Router>
178+
<ErrorBoundary fallback={handleRouteError}>
179+
<Suspense fallback={
180+
<div class="min-h-screen flex items-center justify-center bg-base-200">
181+
<span class="loading loading-spinner loading-lg" aria-label="Loading" />
182+
</div>
183+
}>
184+
<Router>
185+
<Route path="/" component={RootRedirect} />
186+
<Route path="/login" component={LoginPage} />
187+
<Route path="/oauth/callback" component={OAuthCallback} />
188+
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
189+
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
190+
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
191+
<Route path="/privacy" component={PrivacyPage} />
192+
</Router>
193+
</Suspense>
194+
</ErrorBoundary>
153195
);
154196
}

src/app/lib/sentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function scrubUrl(url: string): string {
1111

1212
/** Allowed console breadcrumb prefixes — drop everything else. */
1313
const ALLOWED_CONSOLE_PREFIXES = [
14+
"[app]",
1415
"[auth]",
1516
"[api]",
1617
"[poll]",

src/app/pages/LoginPage.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createSignal, Show } from "solid-js";
1+
import { createSignal, onMount, Show } from "solid-js";
22
import { useNavigate } from "@solidjs/router";
33
import { setAuthFromPat, type GitHubUser } from "../stores/auth";
44
import {
@@ -11,6 +11,19 @@ import { buildAuthorizeUrl } from "../lib/oauth";
1111
export default function LoginPage() {
1212
const navigate = useNavigate();
1313

14+
onMount(() => {
15+
// Speculatively prefetch the dashboard chunk while the user is on the
16+
// login page. By the time they authenticate, the chunk is cached.
17+
const prefetch = () => {
18+
import("../components/dashboard/DashboardPage").catch(() => {
19+
console.warn("[app] Dashboard chunk prefetch failed");
20+
});
21+
};
22+
"requestIdleCallback" in window
23+
? requestIdleCallback(prefetch)
24+
: setTimeout(prefetch, 2000);
25+
});
26+
1427
const [showPatForm, setShowPatForm] = createSignal(false);
1528
const [patInput, setPatInput] = createSignal("");
1629
const [patError, setPatError] = createSignal<string | null>(null);

src/worker/index.ts

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,21 +80,31 @@ function getCorsHeaders(
8080
// The envelope DSN is validated against env.SENTRY_DSN to prevent open proxy abuse.
8181
const SENTRY_ENVELOPE_MAX_BYTES = 256 * 1024; // 256 KB — Sentry rejects >200KB compressed
8282

83-
let _dsnCache: { dsn: string; parsed: { host: string; projectId: string } | null } | undefined;
83+
interface ParsedDsn { host: string; projectId: string; publicKey: string }
8484

85-
/** Parse host and project ID from a Sentry DSN URL. Returns null if invalid. */
86-
function parseSentryDsn(dsn: string): { host: string; projectId: string } | null {
85+
let _dsnCache: { dsn: string; parsed: ParsedDsn | null } | undefined;
86+
87+
/** Parse host, project ID, and public key from a Sentry DSN URL. Returns null if invalid. */
88+
function parseSentryDsn(dsn: string): ParsedDsn | null {
8789
if (!dsn) return null;
8890
try {
8991
const url = new URL(dsn);
9092
const projectId = url.pathname.split("/").filter(Boolean).pop() ?? "";
91-
if (!url.hostname || !projectId) return null;
92-
return { host: url.hostname, projectId };
93+
if (!url.hostname || !projectId || !url.username) return null;
94+
return { host: url.hostname, projectId, publicKey: url.username };
9395
} catch {
9496
return null;
9597
}
9698
}
9799

100+
/** Get cached parsed DSN, re-parsing only when the DSN string changes. */
101+
function getOrCacheDsn(env: Env): ParsedDsn | null {
102+
if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) {
103+
_dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) };
104+
}
105+
return _dsnCache.parsed;
106+
}
107+
98108
async function handleSentryTunnel(
99109
request: Request,
100110
env: Env,
@@ -103,10 +113,7 @@ async function handleSentryTunnel(
103113
return new Response(null, { status: 405, headers: SECURITY_HEADERS });
104114
}
105115

106-
if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) {
107-
_dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) };
108-
}
109-
const allowedDsn = _dsnCache.parsed;
116+
const allowedDsn = getOrCacheDsn(env);
110117
if (!allowedDsn) {
111118
log("warn", "sentry_tunnel_not_configured", {}, request);
112119
return new Response(null, { status: 404, headers: SECURITY_HEADERS });
@@ -186,6 +193,106 @@ async function handleSentryTunnel(
186193
}
187194
}
188195

196+
// ── CSP report tunnel ────────────────────────────────────────────────────
197+
// Receives browser CSP violation reports, scrubs OAuth params from URLs,
198+
// then forwards to Sentry's security ingest endpoint.
199+
const CSP_REPORT_MAX_BYTES = 64 * 1024;
200+
const CSP_OAUTH_PARAMS_RE = /([?&])(code|state|access_token)=[^&\s]*/g;
201+
202+
function scrubReportUrl(url: unknown): string | undefined {
203+
if (typeof url !== "string") return undefined;
204+
return url.replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]");
205+
}
206+
207+
function scrubCspReportBody(body: Record<string, unknown>): Record<string, unknown> {
208+
const scrubbed = { ...body };
209+
// Legacy report-uri format uses kebab-case keys
210+
for (const key of ["document-uri", "blocked-uri", "source-file", "referrer"]) {
211+
if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]);
212+
}
213+
// report-to format uses camelCase keys
214+
for (const key of ["documentURL", "blockedURL", "sourceFile", "referrer"]) {
215+
if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]);
216+
}
217+
return scrubbed;
218+
}
219+
220+
async function handleCspReport(request: Request, env: Env): Promise<Response> {
221+
if (request.method !== "POST") {
222+
return new Response(null, { status: 405, headers: SECURITY_HEADERS });
223+
}
224+
225+
const allowedDsn = getOrCacheDsn(env);
226+
if (!allowedDsn) {
227+
return new Response(null, { status: 404, headers: SECURITY_HEADERS });
228+
}
229+
230+
let bodyText: string;
231+
try {
232+
bodyText = await request.text();
233+
} catch {
234+
return new Response(null, { status: 400, headers: SECURITY_HEADERS });
235+
}
236+
237+
if (bodyText.length > CSP_REPORT_MAX_BYTES) {
238+
log("warn", "csp_report_too_large", { body_length: bodyText.length }, request);
239+
return new Response(null, { status: 413, headers: SECURITY_HEADERS });
240+
}
241+
242+
const contentType = request.headers.get("Content-Type") ?? "";
243+
let scrubbedPayloads: Array<Record<string, unknown>> = [];
244+
245+
try {
246+
if (contentType.includes("application/reports+json")) {
247+
// report-to format: array of report objects
248+
const reports = JSON.parse(bodyText) as Array<{ type?: string; body?: Record<string, unknown> }>;
249+
for (const report of reports) {
250+
if (report.type === "csp-violation" && report.body) {
251+
scrubbedPayloads.push({ "csp-report": scrubCspReportBody(report.body) });
252+
}
253+
}
254+
} else {
255+
// Legacy report-uri format: { "csp-report": { ... } }
256+
const parsed = JSON.parse(bodyText) as { "csp-report"?: Record<string, unknown> };
257+
if (parsed["csp-report"]) {
258+
scrubbedPayloads.push({ "csp-report": scrubCspReportBody(parsed["csp-report"]) });
259+
}
260+
}
261+
} catch {
262+
log("warn", "csp_report_parse_failed", {}, request);
263+
return new Response(null, { status: 400, headers: SECURITY_HEADERS });
264+
}
265+
266+
if (scrubbedPayloads.length === 0) {
267+
return new Response(null, { status: 204, headers: SECURITY_HEADERS });
268+
}
269+
270+
// Cap fan-out to prevent amplification from crafted report-to batches
271+
if (scrubbedPayloads.length > 20) {
272+
scrubbedPayloads = scrubbedPayloads.slice(0, 20);
273+
}
274+
275+
// Sentry security endpoint expects individual csp-report JSON objects
276+
const sentryUrl = `https://${allowedDsn.host}/api/${allowedDsn.projectId}/security/?sentry_key=${allowedDsn.publicKey}`;
277+
278+
const results = await Promise.all(
279+
scrubbedPayloads.map((payload) =>
280+
fetch(sentryUrl, {
281+
method: "POST",
282+
headers: { "Content-Type": "application/csp-report" },
283+
body: JSON.stringify(payload),
284+
}).catch(() => null)
285+
)
286+
);
287+
288+
log("info", "csp_report_forwarded", {
289+
count: scrubbedPayloads.length,
290+
sentry_ok: results.some((r) => r?.ok),
291+
}, request);
292+
293+
return new Response(null, { status: 204, headers: SECURITY_HEADERS });
294+
}
295+
189296
// GitHub OAuth code format validation (SDR-005): alphanumeric, 1-40 chars.
190297
// GitHub's code format is undocumented and has changed historically — validate
191298
// loosely here; GitHub's server validates the actual code.
@@ -350,6 +457,11 @@ export default {
350457
return handleSentryTunnel(request, env);
351458
}
352459

460+
// CSP report tunnel — scrubs OAuth params before forwarding to Sentry
461+
if (url.pathname === "/api/csp-report") {
462+
return handleCspReport(request, env);
463+
}
464+
353465
if (url.pathname === "/api/oauth/token") {
354466
return handleTokenExchange(request, env, cors);
355467
}

0 commit comments

Comments
 (0)