Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion public/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
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
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
Reporting-Endpoints: csp-endpoint="/api/csp-report"
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Expand Down
70 changes: 56 additions & 14 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { createSignal, createEffect, onMount, Show, type JSX } from "solid-js";
import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js";
import { Router, Route, Navigate, useNavigate } from "@solidjs/router";
import { isAuthenticated, validateToken } from "./stores/auth";
import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth";
import { config, initConfigPersistence, resolveTheme } from "./stores/config";
import { initViewPersistence } from "./stores/view";
import { evictStaleEntries } from "./stores/cache";
import { initClientWatcher } from "./services/github";
import LoginPage from "./pages/LoginPage";
import OAuthCallback from "./pages/OAuthCallback";
import DashboardPage from "./components/dashboard/DashboardPage";
import OnboardingWizard from "./components/onboarding/OnboardingWizard";
import SettingsPage from "./components/settings/SettingsPage";
import PrivacyPage from "./pages/PrivacyPage";

const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage"));
const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard"));
const SettingsPage = lazy(() => import("./components/settings/SettingsPage"));

function handleRouteError(err: unknown) {
console.error("[app] Route render failed:", err);
return <ChunkErrorFallback />;
}

function ChunkErrorFallback() {
return (
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card bg-base-100 shadow-md p-8 flex flex-col items-center gap-4 max-w-sm">
<p class="text-error font-medium">Failed to load page</p>
<p class="text-sm text-base-content/60 text-center">
A new version may have been deployed. Reloading should fix this.
</p>
<button
type="button"
class="btn btn-neutral"
onClick={() => window.location.reload()}
>
Reload page
</button>
</div>
</div>
);
}

// Auth guard: redirects unauthenticated users to /login.
// On page load, validates the localStorage token with GitHub API.
function AuthGuard(props: { children: JSX.Element }) {
Expand Down Expand Up @@ -138,17 +164,33 @@ export default function App() {
evictStaleEntries(24 * 60 * 60 * 1000).catch(() => {
// Non-fatal — stale eviction failure is acceptable
});

// Preload dashboard chunk in parallel with token validation to avoid
// a sequential waterfall (validateToken → chunk fetch)
if (localStorage.getItem?.(AUTH_STORAGE_KEY)) {
import("./components/dashboard/DashboardPage").catch(() => {
console.warn("[app] Dashboard chunk preload failed");
});
}
});

return (
<Router>
<Route path="/" component={RootRedirect} />
<Route path="/login" component={LoginPage} />
<Route path="/oauth/callback" component={OAuthCallback} />
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
<Route path="/privacy" component={PrivacyPage} />
</Router>
<ErrorBoundary fallback={handleRouteError}>
<Suspense fallback={
<div class="min-h-screen flex items-center justify-center bg-base-200">
<span class="loading loading-spinner loading-lg" aria-label="Loading" />
</div>
}>
<Router>
<Route path="/" component={RootRedirect} />
<Route path="/login" component={LoginPage} />
<Route path="/oauth/callback" component={OAuthCallback} />
<Route path="/onboarding" component={() => <AuthGuard><OnboardingWizard /></AuthGuard>} />
<Route path="/dashboard" component={() => <AuthGuard><DashboardPage /></AuthGuard>} />
<Route path="/settings" component={() => <AuthGuard><SettingsPage /></AuthGuard>} />
<Route path="/privacy" component={PrivacyPage} />
</Router>
</Suspense>
</ErrorBoundary>
);
}
1 change: 1 addition & 0 deletions src/app/lib/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function scrubUrl(url: string): string {

/** Allowed console breadcrumb prefixes — drop everything else. */
const ALLOWED_CONSOLE_PREFIXES = [
"[app]",
"[auth]",
"[api]",
"[poll]",
Expand Down
15 changes: 14 additions & 1 deletion src/app/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSignal, Show } from "solid-js";
import { createSignal, onMount, Show } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { setAuthFromPat, type GitHubUser } from "../stores/auth";
import {
Expand All @@ -11,6 +11,19 @@ import { buildAuthorizeUrl } from "../lib/oauth";
export default function LoginPage() {
const navigate = useNavigate();

onMount(() => {
// Speculatively prefetch the dashboard chunk while the user is on the
// login page. By the time they authenticate, the chunk is cached.
const prefetch = () => {
import("../components/dashboard/DashboardPage").catch(() => {
console.warn("[app] Dashboard chunk prefetch failed");
});
};
"requestIdleCallback" in window
? requestIdleCallback(prefetch)
: setTimeout(prefetch, 2000);
});

const [showPatForm, setShowPatForm] = createSignal(false);
const [patInput, setPatInput] = createSignal("");
const [patError, setPatError] = createSignal<string | null>(null);
Expand Down
130 changes: 121 additions & 9 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,31 @@ function getCorsHeaders(
// The envelope DSN is validated against env.SENTRY_DSN to prevent open proxy abuse.
const SENTRY_ENVELOPE_MAX_BYTES = 256 * 1024; // 256 KB — Sentry rejects >200KB compressed

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

/** Parse host and project ID from a Sentry DSN URL. Returns null if invalid. */
function parseSentryDsn(dsn: string): { host: string; projectId: string } | null {
let _dsnCache: { dsn: string; parsed: ParsedDsn | null } | undefined;

/** Parse host, project ID, and public key from a Sentry DSN URL. Returns null if invalid. */
function parseSentryDsn(dsn: string): ParsedDsn | null {
if (!dsn) return null;
try {
const url = new URL(dsn);
const projectId = url.pathname.split("/").filter(Boolean).pop() ?? "";
if (!url.hostname || !projectId) return null;
return { host: url.hostname, projectId };
if (!url.hostname || !projectId || !url.username) return null;
return { host: url.hostname, projectId, publicKey: url.username };
} catch {
return null;
}
}

/** Get cached parsed DSN, re-parsing only when the DSN string changes. */
function getOrCacheDsn(env: Env): ParsedDsn | null {
if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) {
_dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) };
}
return _dsnCache.parsed;
}

async function handleSentryTunnel(
request: Request,
env: Env,
Expand All @@ -103,10 +113,7 @@ async function handleSentryTunnel(
return new Response(null, { status: 405, headers: SECURITY_HEADERS });
}

if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) {
_dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) };
}
const allowedDsn = _dsnCache.parsed;
const allowedDsn = getOrCacheDsn(env);
if (!allowedDsn) {
log("warn", "sentry_tunnel_not_configured", {}, request);
return new Response(null, { status: 404, headers: SECURITY_HEADERS });
Expand Down Expand Up @@ -186,6 +193,106 @@ async function handleSentryTunnel(
}
}

// ── CSP report tunnel ────────────────────────────────────────────────────
// Receives browser CSP violation reports, scrubs OAuth params from URLs,
// then forwards to Sentry's security ingest endpoint.
const CSP_REPORT_MAX_BYTES = 64 * 1024;
const CSP_OAUTH_PARAMS_RE = /([?&])(code|state|access_token)=[^&\s]*/g;

function scrubReportUrl(url: unknown): string | undefined {
if (typeof url !== "string") return undefined;
return url.replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]");
}

function scrubCspReportBody(body: Record<string, unknown>): Record<string, unknown> {
const scrubbed = { ...body };
// Legacy report-uri format uses kebab-case keys
for (const key of ["document-uri", "blocked-uri", "source-file", "referrer"]) {
if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]);
}
// report-to format uses camelCase keys
for (const key of ["documentURL", "blockedURL", "sourceFile", "referrer"]) {
if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]);
}
return scrubbed;
}

async function handleCspReport(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response(null, { status: 405, headers: SECURITY_HEADERS });
}

const allowedDsn = getOrCacheDsn(env);
if (!allowedDsn) {
return new Response(null, { status: 404, headers: SECURITY_HEADERS });
}

let bodyText: string;
try {
bodyText = await request.text();
} catch {
return new Response(null, { status: 400, headers: SECURITY_HEADERS });
}

if (bodyText.length > CSP_REPORT_MAX_BYTES) {
log("warn", "csp_report_too_large", { body_length: bodyText.length }, request);
return new Response(null, { status: 413, headers: SECURITY_HEADERS });
}

const contentType = request.headers.get("Content-Type") ?? "";
let scrubbedPayloads: Array<Record<string, unknown>> = [];

try {
if (contentType.includes("application/reports+json")) {
// report-to format: array of report objects
const reports = JSON.parse(bodyText) as Array<{ type?: string; body?: Record<string, unknown> }>;
for (const report of reports) {
if (report.type === "csp-violation" && report.body) {
scrubbedPayloads.push({ "csp-report": scrubCspReportBody(report.body) });
}
}
} else {
// Legacy report-uri format: { "csp-report": { ... } }
const parsed = JSON.parse(bodyText) as { "csp-report"?: Record<string, unknown> };
if (parsed["csp-report"]) {
scrubbedPayloads.push({ "csp-report": scrubCspReportBody(parsed["csp-report"]) });
}
}
} catch {
log("warn", "csp_report_parse_failed", {}, request);
return new Response(null, { status: 400, headers: SECURITY_HEADERS });
}

if (scrubbedPayloads.length === 0) {
return new Response(null, { status: 204, headers: SECURITY_HEADERS });
}

// Cap fan-out to prevent amplification from crafted report-to batches
if (scrubbedPayloads.length > 20) {
scrubbedPayloads = scrubbedPayloads.slice(0, 20);
}

// Sentry security endpoint expects individual csp-report JSON objects
const sentryUrl = `https://${allowedDsn.host}/api/${allowedDsn.projectId}/security/?sentry_key=${allowedDsn.publicKey}`;

const results = await Promise.all(
scrubbedPayloads.map((payload) =>
fetch(sentryUrl, {
method: "POST",
headers: { "Content-Type": "application/csp-report" },
body: JSON.stringify(payload),
}).catch(() => null)
)
);

log("info", "csp_report_forwarded", {
count: scrubbedPayloads.length,
sentry_ok: results.some((r) => r?.ok),
}, request);

return new Response(null, { status: 204, headers: SECURITY_HEADERS });
}

// GitHub OAuth code format validation (SDR-005): alphanumeric, 1-40 chars.
// GitHub's code format is undocumented and has changed historically — validate
// loosely here; GitHub's server validates the actual code.
Expand Down Expand Up @@ -350,6 +457,11 @@ export default {
return handleSentryTunnel(request, env);
}

// CSP report tunnel — scrubs OAuth params before forwarding to Sentry
if (url.pathname === "/api/csp-report") {
return handleCspReport(request, env);
}

if (url.pathname === "/api/oauth/token") {
return handleTokenExchange(request, env, cors);
}
Expand Down
Loading
Loading