diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e18ee99..d877e5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,10 @@ jobs: - run: pnpm test - name: Verify CSP hash run: node scripts/verify-csp-hash.mjs + - name: WAF smoke tests + run: pnpm test:waf + env: + WAF_BYPASS_TOKEN: ${{ secrets.WAF_BYPASS_TOKEN }} - run: pnpm run build env: VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} diff --git a/package.json b/package.json index ec70e3e..585f87f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest --config vitest.workspace.ts", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", - "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test" + "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test", + "test:waf": "bash scripts/waf-smoke-test.sh" }, "dependencies": { "@kobalte/core": "^0.13.11", @@ -20,6 +21,7 @@ "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.1.0", "@octokit/plugin-throttling": "^11.0.3", + "@sentry/solid": "^10.46.0", "@solidjs/router": "^0.16.1", "corvu": "^0.7.2", "idb": "^8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1711d83..3e96fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@octokit/plugin-throttling': specifier: ^11.0.3 version: 11.0.3(@octokit/core@7.0.6) + '@sentry/solid': + specifier: ^10.46.0 + version: 10.46.0(@solidjs/router@0.16.1(solid-js@1.9.11))(solid-js@1.9.11) '@solidjs/router': specifier: ^0.16.1 version: 0.16.1(solid-js@1.9.11) @@ -815,6 +818,43 @@ packages: '@rolldown/pluginutils@1.0.0-rc.10': resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + '@sentry-internal/browser-utils@10.46.0': + resolution: {integrity: sha512-WB1gBT9G13V02ekZ6NpUhoI1aGHV2eNfjEPthkU2bGBvFpQKnstwzjg7waIRGR7cu+YSW2Q6UI6aQLgBeOPD1g==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.46.0': + resolution: {integrity: sha512-c4pI/z9nZCQXe9GYEw/hE/YTY9AxGBp8/wgKI+T8zylrN35SGHaXv63szzE1WbI8lacBY8lBF7rstq9bQVCaHw==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.46.0': + resolution: {integrity: sha512-ub314MWUsekVCuoH0/HJbbimlI24SkV745UW2pj9xRbxOAEf1wjkmIzxKrMDbTgJGuEunug02XZVdJFJUzOcDw==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.46.0': + resolution: {integrity: sha512-JBsWeXG6bRbxBFK8GzWymWGOB9QE7Kl57BeF3jzgdHTuHSWZ2mRnAmb1K05T4LU+gVygk6yW0KmdC8Py9Qzg9A==} + engines: {node: '>=18'} + + '@sentry/browser@10.46.0': + resolution: {integrity: sha512-80DmGlTk5Z2/OxVOzLNxwolMyouuAYKqG8KUcoyintZqHbF6kO1RulI610HmyUt3OagKeBCqt9S7w0VIfCRL+Q==} + engines: {node: '>=18'} + + '@sentry/core@10.46.0': + resolution: {integrity: sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q==} + engines: {node: '>=18'} + + '@sentry/solid@10.46.0': + resolution: {integrity: sha512-OR/yj/jxs+wCsaJ6Nh7PtS9+FHbezs2oH/ZBfs9438Nqn99iLPYxVpH9CSN+AGtyrPWxSI5MCh5VP8LZTlUDTw==} + engines: {node: '>=18'} + peerDependencies: + '@solidjs/router': ^0.13.4 || ^0.14.0 || ^0.15.0 + '@tanstack/solid-router': ^1.132.27 + solid-js: ^1.8.4 + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@tanstack/solid-router': + optional: true + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2351,6 +2391,42 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.10': {} + '@sentry-internal/browser-utils@10.46.0': + dependencies: + '@sentry/core': 10.46.0 + + '@sentry-internal/feedback@10.46.0': + dependencies: + '@sentry/core': 10.46.0 + + '@sentry-internal/replay-canvas@10.46.0': + dependencies: + '@sentry-internal/replay': 10.46.0 + '@sentry/core': 10.46.0 + + '@sentry-internal/replay@10.46.0': + dependencies: + '@sentry-internal/browser-utils': 10.46.0 + '@sentry/core': 10.46.0 + + '@sentry/browser@10.46.0': + dependencies: + '@sentry-internal/browser-utils': 10.46.0 + '@sentry-internal/feedback': 10.46.0 + '@sentry-internal/replay': 10.46.0 + '@sentry-internal/replay-canvas': 10.46.0 + '@sentry/core': 10.46.0 + + '@sentry/core@10.46.0': {} + + '@sentry/solid@10.46.0(@solidjs/router@0.16.1(solid-js@1.9.11))(solid-js@1.9.11)': + dependencies: + '@sentry/browser': 10.46.0 + '@sentry/core': 10.46.0 + solid-js: 1.9.11 + optionalDependencies: + '@solidjs/router': 0.16.1(solid-js@1.9.11) + '@sindresorhus/is@7.2.0': {} '@solid-primitives/event-listener@2.4.5(solid-js@1.9.11)': diff --git a/prek.toml b/prek.toml index 3416b9e..d368da6 100644 --- a/prek.toml +++ b/prek.toml @@ -40,6 +40,15 @@ pass_filenames = false always_run = true priority = 0 +[[repos.hooks]] +id = "waf" +name = "WAF smoke tests" +language = "system" +entry = "pnpm test:waf" +pass_filenames = false +always_run = true +priority = 0 + [[repos.hooks]] id = "e2e" name = "Playwright E2E tests" diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh new file mode 100755 index 0000000..a23d05b --- /dev/null +++ b/scripts/waf-smoke-test.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev +# +# Usage: pnpm test:waf +# +# Rules validated: +# 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/* +# 2. Scanner User-Agents — challenges empty/malicious User-Agent strings +# Rate limit rule exists but is not tested here (triggers a 10-minute IP block). + +set -euo pipefail + +BASE="https://gh.gordoncode.dev" +PASS=0 +FAIL=0 + +# When WAF_BYPASS_TOKEN is set (CI), send a header that a Cloudflare WAF rule +# uses to skip Bot Fight Mode for this request. Without it (local dev), requests +# pass through normally since residential IPs aren't challenged. +BYPASS=() +if [[ -n "${WAF_BYPASS_TOKEN:-}" ]]; then + BYPASS=(-H "X-CI-Bypass: ${WAF_BYPASS_TOKEN}") +fi + +assert_status() { + local expected="$1" actual="$2" label="$3" + if [[ "$actual" == "$expected" ]]; then + echo " PASS [${actual}] ${label}" + PASS=$((PASS + 1)) + else + echo " FAIL [${actual}] ${label} (expected ${expected})" + FAIL=$((FAIL + 1)) + fi +} + +fetch() { + curl -s -o /dev/null -w "%{http_code}" "${BYPASS[@]}" "$@" +} + +# ============================================================ +# Rule 1: Path Allowlist +# ============================================================ +echo "=== Rule 1: Path Allowlist ===" +echo "--- Allowed paths (should pass) ---" + +for path in "/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy"; do + status=$(fetch "${BASE}${path}") + assert_status "200" "$status" "GET ${path}" +done + +status=$(fetch "${BASE}/index.html") +assert_status "307" "$status" "GET /index.html (html_handling redirect)" + +status=$(fetch "${BASE}/assets/nonexistent.js") +assert_status "200" "$status" "GET /assets/nonexistent.js" + +status=$(fetch "${BASE}/api/health") +assert_status "200" "$status" "GET /api/health" + +status=$(fetch -X POST "${BASE}/api/oauth/token") +assert_status "400" "$status" "POST /api/oauth/token (no body)" + +status=$(fetch "${BASE}/api/nonexistent") +assert_status "404" "$status" "GET /api/nonexistent" + +echo "--- Blocked paths (should be 403) ---" + +for path in "/wp-admin" "/wp-login.php" "/.env" "/.env.production" \ + "/.git/config" "/.git/HEAD" "/xmlrpc.php" \ + "/phpmyadmin/" "/phpMyAdmin/" "/.htaccess" "/.htpasswd" \ + "/cgi-bin/" "/admin/" "/wp-content/debug.log" \ + "/config.php" "/backup.zip" "/actuator/health" \ + "/manager/html" "/wp-config.php" "/eval-stdin.php" \ + "/.aws/credentials" "/.ssh/id_rsa" "/robots.txt" \ + "/sitemap.xml" "/favicon.ico" "/random/garbage/path"; do + status=$(fetch "${BASE}${path}") + assert_status "403" "$status" "GET ${path}" +done + +# ============================================================ +# Rule 2: Scanner User-Agents +# ============================================================ +echo "" +echo "=== Rule 2: Scanner User-Agents ===" +echo "--- Normal UAs (should pass) ---" + +status=$(fetch -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" "${BASE}/") +assert_status "200" "$status" "Normal browser UA" + +status=$(fetch "${BASE}/") +assert_status "200" "$status" "Default curl UA" + +echo "--- Malicious UAs (should be 403 — managed challenge, no JS) ---" + +status=$(fetch -H "User-Agent:" "${BASE}/") +assert_status "403" "$status" "Empty User-Agent" + +for ua in "sqlmap/1.7" "Nikto/2.1.6" "Nmap Scripting Engine" "masscan/1.3" "Mozilla/5.0 zgrab/0.x"; do + status=$(fetch -H "User-Agent: ${ua}" "${BASE}/") + assert_status "403" "$status" "UA: ${ua}" +done + +# ============================================================ +# Summary +# ============================================================ +echo "" +TOTAL=$((PASS + FAIL)) +echo "=== Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ===" +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi diff --git a/src/app/index.tsx b/src/app/index.tsx index 51793ba..da9f83b 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,5 +1,10 @@ import "./index.css"; import { render } from "solid-js/web"; +import { initSentry } from "./lib/sentry"; import App from "./App"; +// Initialize Sentry before rendering — captures errors from first paint. +// No-ops in dev/test (guarded by import.meta.env.DEV check). +initSentry(); + render(() => , document.getElementById("app")!); diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts new file mode 100644 index 0000000..181dda6 --- /dev/null +++ b/src/app/lib/sentry.ts @@ -0,0 +1,104 @@ +import * as Sentry from "@sentry/solid"; +import type { ErrorEvent, Breadcrumb } from "@sentry/solid"; + +/** Strip OAuth credentials from any captured URL or query string. */ +export function scrubUrl(url: string): string { + return url + .replace(/code=[^&\s]+/g, "code=[REDACTED]") + .replace(/state=[^&\s]+/g, "state=[REDACTED]") + .replace(/access_token=[^&\s]+/g, "access_token=[REDACTED]"); +} + +/** Allowed console breadcrumb prefixes — drop everything else. */ +const ALLOWED_CONSOLE_PREFIXES = [ + "[auth]", + "[api]", + "[poll]", + "[dashboard]", + "[settings]", +]; + +const SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240"; + +export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { + // Strip OAuth params from captured URLs + if (event.request?.url) { + event.request.url = scrubUrl(event.request.url); + } + if (event.request?.query_string) { + event.request.query_string = + typeof event.request.query_string === "string" + ? scrubUrl(event.request.query_string) + : "[REDACTED]"; + } + // Remove headers and cookies entirely + delete event.request?.headers; + delete event.request?.cookies; + // Remove user identity — we never want to track users + delete event.user; + // Scrub URLs in stack trace frames + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.stacktrace?.frames) { + for (const frame of ex.stacktrace.frames) { + if (frame.abs_path) { + frame.abs_path = scrubUrl(frame.abs_path); + } + } + } + } + } + return event; +} + +export function beforeBreadcrumbHandler( + breadcrumb: Breadcrumb, +): Breadcrumb | null { + // Scrub URLs in navigation breadcrumbs + if (breadcrumb.category === "navigation") { + if (breadcrumb.data?.from) + breadcrumb.data.from = scrubUrl(breadcrumb.data.from as string); + if (breadcrumb.data?.to) + breadcrumb.data.to = scrubUrl(breadcrumb.data.to as string); + } + // Scrub URLs in fetch/xhr breadcrumbs + if ( + breadcrumb.category === "fetch" || + breadcrumb.category === "xhr" + ) { + if (breadcrumb.data?.url) + breadcrumb.data.url = scrubUrl(breadcrumb.data.url as string); + } + // Only keep our own tagged console logs — drop third-party noise + if (breadcrumb.category === "console") { + const msg = breadcrumb.message ?? ""; + if (!ALLOWED_CONSOLE_PREFIXES.some((p) => msg.startsWith(p))) { + return null; + } + } + return breadcrumb; +} + +export function initSentry(): void { + if (import.meta.env.DEV || !SENTRY_DSN) return; + + Sentry.init({ + dsn: SENTRY_DSN, + tunnel: "/api/error-reporting", + environment: import.meta.env.MODE, + + // ── Privacy: absolute minimum data ────────────────────────── + sendDefaultPii: false, + + // ── Disable everything except error tracking ──────────────── + tracesSampleRate: 0, + profilesSampleRate: 0, + + // ── Only capture errors from our own code ─────────────────── + allowUrls: [/^https:\/\/gh\.gordoncode\.dev/], + + // ── Scrub sensitive data before it leaves the browser ──────── + beforeSend: beforeSendHandler, + beforeBreadcrumb: beforeBreadcrumbHandler, + }); +} diff --git a/src/app/pages/PrivacyPage.tsx b/src/app/pages/PrivacyPage.tsx index 84a3011..410fffc 100644 --- a/src/app/pages/PrivacyPage.tsx +++ b/src/app/pages/PrivacyPage.tsx @@ -15,12 +15,13 @@ export default function PrivacyPage() {

- GitHub Tracker does not collect, store, or transmit any personal - data. All data stays in your browser. + GitHub Tracker stores your data in your browser. We use a + privacy-hardened error monitoring service to diagnose issues, as + described below.

- What we store + What we store in your browser

+

+ Error monitoring +

+

+ We use{" "} + + Sentry + {" "} + (Functional Software, Inc.) to capture JavaScript errors so we can + fix bugs. When an error occurs, the following is sent: +

+ +

+ What is never sent: IP addresses, cookies, request + headers, user identity, access tokens, OAuth codes, DOM content, + screen recordings, keystrokes, or performance traces. All sensitive + URL parameters are stripped before data leaves your browser. +

+

+ Error data is stored on Sentry's US-based infrastructure and + retained per Sentry's{" "} + + privacy policy + . Error monitoring is disabled during local development. +

+

What we don't do

diff --git a/src/worker/index.ts b/src/worker/index.ts index 04969d5..52700d2 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,6 +3,7 @@ export interface Env { GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; ALLOWED_ORIGIN: string; + SENTRY_DSN: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123" } // Predefined error strings only (SDR-006) @@ -12,6 +13,30 @@ type ErrorCode = | "method_not_allowed" | "not_found"; +// Structured logging — Cloudflare auto-indexes JSON fields for querying. +// NEVER log secrets: codes, tokens, client_secret, cookie values. +function log( + level: "info" | "warn" | "error", + event: string, + data: Record, + request?: Request +): void { + const entry: Record = { + worker: "github-tracker", + event, + ...data, + }; + if (request) { + const cf = (request as unknown as { cf?: Record }).cf; + entry.origin = request.headers.get("Origin"); + entry.user_agent = request.headers.get("User-Agent"); + entry.cf_country = cf?.country; + entry.cf_colo = cf?.colo; + entry.cf_city = cf?.city; + } + console[level](JSON.stringify(entry)); +} + function errorResponse( code: ErrorCode, status: number, @@ -22,18 +47,16 @@ function errorResponse( headers: { "Content-Type": "application/json", ...corsHeaders, - ...securityHeaders(), + ...SECURITY_HEADERS, }, }); } -function securityHeaders(): Record { - return { - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Frame-Options": "DENY", - }; -} +const SECURITY_HEADERS: Record = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", +}; // CORS: strict equality only (SDR-004) function getCorsHeaders( @@ -51,6 +74,118 @@ function getCorsHeaders( return {}; } +// ── Sentry tunnel ───────────────────────────────────────────────────────── +// Proxies Sentry event envelopes through our own domain so the browser +// treats them as same-origin (no CSP change, no ad-blocker interference). +// 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; + +/** Parse host and project ID from a Sentry DSN URL. Returns null if invalid. */ +function parseSentryDsn(dsn: string): { host: string; projectId: string } | 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 }; + } catch { + return null; + } +} + +async function handleSentryTunnel( + request: Request, + env: Env, +): Promise { + if (request.method !== "POST") { + 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; + if (!allowedDsn) { + log("warn", "sentry_tunnel_not_configured", {}, request); + return new Response(null, { status: 404, headers: SECURITY_HEADERS }); + } + + let body: string; + try { + body = await request.text(); + } catch { + log("warn", "sentry_tunnel_body_read_failed", {}, request); + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + if (body.length > SENTRY_ENVELOPE_MAX_BYTES) { + log("warn", "sentry_tunnel_payload_too_large", { body_length: body.length }, request); + return new Response(null, { status: 413, headers: SECURITY_HEADERS }); + } + + // Sentry envelope format: first line is JSON header with dsn field + const firstNewline = body.indexOf("\n"); + if (firstNewline === -1) { + log("warn", "sentry_tunnel_invalid_envelope", {}, request); + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + let envelopeHeader: { dsn?: string }; + try { + envelopeHeader = JSON.parse(body.substring(0, firstNewline)); + } catch { + log("warn", "sentry_tunnel_header_parse_failed", {}, request); + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + if (typeof envelopeHeader.dsn !== "string") { + // client_report envelopes may omit dsn — drop silently + log("info", "sentry_tunnel_no_dsn", {}, request); + return new Response(null, { status: 200, headers: SECURITY_HEADERS }); + } + + // Validate envelope DSN matches our project — prevents open proxy abuse + const envelopeDsn = parseSentryDsn(envelopeHeader.dsn); + if (!envelopeDsn) { + log("warn", "sentry_tunnel_invalid_dsn", {}, request); + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + if (envelopeDsn.host !== allowedDsn.host || envelopeDsn.projectId !== allowedDsn.projectId) { + log("warn", "sentry_tunnel_dsn_mismatch", { + dsn_host: envelopeDsn.host, + dsn_project: envelopeDsn.projectId, + }, request); + return new Response(null, { status: 403, headers: SECURITY_HEADERS }); + } + + // Forward to Sentry ingest endpoint + const sentryUrl = `https://${allowedDsn.host}/api/${allowedDsn.projectId}/envelope/`; + try { + const sentryResp = await fetch(sentryUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body, + }); + + log("info", "sentry_tunnel_forwarded", { + sentry_status: sentryResp.status, + }, request); + + return new Response(null, { + status: sentryResp.status, + headers: SECURITY_HEADERS, + }); + } catch (err) { + log("error", "sentry_tunnel_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + return new Response(null, { status: 502, 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. @@ -62,18 +197,25 @@ async function handleTokenExchange( cors: Record ): Promise { if (request.method !== "POST") { + log("warn", "token_exchange_wrong_method", { method: request.method }, request); return errorResponse("method_not_allowed", 405, cors); } + log("info", "token_exchange_started", {}, request); + const contentType = request.headers.get("Content-Type") ?? ""; if (!contentType.includes("application/json")) { + log("warn", "token_exchange_bad_content_type", { content_type: contentType }, request); return errorResponse("invalid_request", 400, cors); } let body: unknown; try { body = await request.json(); - } catch { + } catch (err) { + log("warn", "token_exchange_json_parse_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); return errorResponse("invalid_request", 400, cors); } @@ -82,6 +224,12 @@ async function handleTokenExchange( body === null || typeof (body as Record)["code"] !== "string" ) { + log("warn", "token_exchange_missing_code", { + has_code: body !== null && typeof body === "object" && "code" in body, + code_type: body !== null && typeof body === "object" && "code" in body + ? typeof (body as Record)["code"] + : "n/a", + }, request); return errorResponse("invalid_request", 400, cors); } @@ -89,10 +237,18 @@ async function handleTokenExchange( // Strict code format validation before touching GitHub (SDR-005) if (!VALID_CODE_RE.test(code)) { + log("warn", "token_exchange_invalid_code_format", { + code_length: code.length, + code_has_spaces: code.includes(" "), + code_has_newlines: code.includes("\n"), + }, request); return errorResponse("invalid_request", 400, cors); } + log("info", "github_oauth_request_sent", {}, request); + let githubData: Record; + let githubStatus: number; try { const githubResp = await fetch( "https://github.com/login/oauth/access_token", @@ -109,8 +265,13 @@ async function handleTokenExchange( }), } ); + githubStatus = githubResp.status; githubData = (await githubResp.json()) as Record; - } catch { + } catch (err) { + log("error", "github_oauth_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + error_name: err instanceof Error ? err.name : "unknown", + }, request); return errorResponse("token_exchange_failed", 400, cors); } @@ -119,9 +280,22 @@ async function handleTokenExchange( typeof githubData["error"] === "string" || typeof githubData["access_token"] !== "string" ) { + log("error", "github_oauth_error_response", { + github_status: githubStatus, + github_error: githubData["error"], + github_error_description: githubData["error_description"], + github_error_uri: githubData["error_uri"], + has_access_token: "access_token" in githubData, + }, request); return errorResponse("token_exchange_failed", 400, cors); } + log("info", "token_exchange_succeeded", { + github_status: githubStatus, + scope: githubData["scope"], + token_type: githubData["token_type"], + }, request); + // Return only allowed fields — never forward full GitHub response. const allowed = { access_token: githubData["access_token"], @@ -134,7 +308,7 @@ async function handleTokenExchange( headers: { "Content-Type": "application/json", ...cors, - ...securityHeaders(), + ...SECURITY_HEADERS, }, }); } @@ -144,26 +318,53 @@ export default { const url = new URL(request.url); const origin = request.headers.get("Origin"); const cors = getCorsHeaders(origin, env.ALLOWED_ORIGIN); + const corsMatched = Object.keys(cors).length > 0; + + // Log all API requests (skip static asset requests to reduce noise) + if (url.pathname.startsWith("/api/")) { + log("info", "api_request", { + method: request.method, + pathname: url.pathname, + cors_matched: corsMatched, + }, request); + + if (!corsMatched && origin !== null) { + log("warn", "cors_origin_mismatch", { + request_origin: origin, + allowed_origin: env.ALLOWED_ORIGIN, + }, request); + } + } // CORS preflight for the token exchange endpoint only if (request.method === "OPTIONS" && url.pathname === "/api/oauth/token") { + log("info", "cors_preflight", { cors_matched: corsMatched }, request); return new Response(null, { status: 204, - headers: { ...cors, "Access-Control-Max-Age": "86400", ...securityHeaders() }, + headers: { ...cors, "Access-Control-Max-Age": "86400", ...SECURITY_HEADERS }, }); } + // Sentry tunnel — same-origin proxy, no CORS needed (browser sends as first-party) + if (url.pathname === "/api/error-reporting") { + return handleSentryTunnel(request, env); + } + if (url.pathname === "/api/oauth/token") { return handleTokenExchange(request, env, cors); } if (url.pathname === "/api/health" && request.method === "GET") { return new Response("OK", { - headers: securityHeaders(), + headers: SECURITY_HEADERS, }); } if (url.pathname.startsWith("/api/")) { + log("warn", "api_not_found", { + method: request.method, + pathname: url.pathname, + }, request); return errorResponse("not_found", 404, cors); } diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts new file mode 100644 index 0000000..5e612a9 --- /dev/null +++ b/tests/lib/sentry.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from "vitest"; +import { + scrubUrl, + beforeSendHandler, + beforeBreadcrumbHandler, +} from "../../src/app/lib/sentry"; + +describe("scrubUrl", () => { + it("strips code= parameter", () => { + expect(scrubUrl("https://example.com/cb?code=abc123&state=xyz")).toBe( + "https://example.com/cb?code=[REDACTED]&state=[REDACTED]", + ); + }); + + it("strips access_token= parameter", () => { + expect(scrubUrl("https://example.com?access_token=ghu_secret")).toBe( + "https://example.com?access_token=[REDACTED]", + ); + }); + + it("strips state= parameter", () => { + expect(scrubUrl("https://example.com?state=random_state_value")).toBe( + "https://example.com?state=[REDACTED]", + ); + }); + + it("strips multiple sensitive params at once", () => { + const url = + "https://example.com/cb?code=abc&state=xyz&access_token=ghu_tok&other=safe"; + const result = scrubUrl(url); + expect(result).toContain("code=[REDACTED]"); + expect(result).toContain("state=[REDACTED]"); + expect(result).toContain("access_token=[REDACTED]"); + expect(result).toContain("other=safe"); + }); + + it("returns URL unchanged when no sensitive params present", () => { + const url = "https://example.com/page?tab=issues&sort=updated"; + expect(scrubUrl(url)).toBe(url); + }); + + it("handles empty string", () => { + expect(scrubUrl("")).toBe(""); + }); + + it("handles params at end of string (no trailing &)", () => { + expect(scrubUrl("https://example.com?code=abc")).toBe( + "https://example.com?code=[REDACTED]", + ); + }); +}); + +describe("beforeSendHandler", () => { + it("scrubs OAuth params from request URL", () => { + const event = { + request: { + url: "https://gh.gordoncode.dev/cb?code=abc123&state=xyz", + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.request!.url).toBe( + "https://gh.gordoncode.dev/cb?code=[REDACTED]&state=[REDACTED]", + ); + }); + + it("scrubs query_string selectively when it is a string", () => { + const event = { + request: { + url: "https://gh.gordoncode.dev/cb", + query_string: "code=abc123&tab=issues", + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.request!.query_string).toBe("code=[REDACTED]&tab=issues"); + }); + + it("redacts query_string entirely when it is not a string", () => { + const event = { + request: { + url: "https://gh.gordoncode.dev/cb", + query_string: [["code", "abc123"]], + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.request!.query_string).toBe("[REDACTED]"); + }); + + it("deletes request headers and cookies", () => { + const event = { + request: { + url: "https://gh.gordoncode.dev", + headers: { Authorization: "Bearer ghu_token" }, + cookies: "session=abc", + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.request!.headers).toBeUndefined(); + expect(result!.request!.cookies).toBeUndefined(); + }); + + it("deletes user identity", () => { + const event = { + request: { url: "https://gh.gordoncode.dev" }, + user: { id: "123", email: "test@example.com" }, + }; + const result = beforeSendHandler(event as never); + expect((result as unknown as Record).user).toBeUndefined(); + }); + + it("scrubs URLs in stack trace frames", () => { + const event = { + request: { url: "https://gh.gordoncode.dev" }, + exception: { + values: [ + { + stacktrace: { + frames: [ + { abs_path: "https://gh.gordoncode.dev/app.js?code=secret" }, + { abs_path: "https://gh.gordoncode.dev/lib.js" }, + ], + }, + }, + ], + }, + }; + const result = beforeSendHandler(event as never); + const frames = result!.exception!.values![0].stacktrace!.frames!; + expect(frames[0].abs_path).toBe( + "https://gh.gordoncode.dev/app.js?code=[REDACTED]", + ); + expect(frames[1].abs_path).toBe("https://gh.gordoncode.dev/lib.js"); + }); + + it("handles events with no request", () => { + const event = {}; + const result = beforeSendHandler(event as never); + expect(result).toBeDefined(); + }); +}); + +describe("beforeBreadcrumbHandler", () => { + it("scrubs navigation breadcrumb URLs", () => { + const breadcrumb = { + category: "navigation", + data: { + from: "https://gh.gordoncode.dev/cb?code=abc", + to: "https://gh.gordoncode.dev/dashboard?state=xyz", + }, + }; + const result = beforeBreadcrumbHandler(breadcrumb as never); + expect(result!.data!.from).toBe( + "https://gh.gordoncode.dev/cb?code=[REDACTED]", + ); + expect(result!.data!.to).toBe( + "https://gh.gordoncode.dev/dashboard?state=[REDACTED]", + ); + }); + + it("scrubs fetch breadcrumb URLs", () => { + const breadcrumb = { + category: "fetch", + data: { url: "https://api.github.com?access_token=ghu_tok" }, + }; + const result = beforeBreadcrumbHandler(breadcrumb as never); + expect(result!.data!.url).toBe( + "https://api.github.com?access_token=[REDACTED]", + ); + }); + + it("scrubs xhr breadcrumb URLs", () => { + const breadcrumb = { + category: "xhr", + data: { url: "https://api.github.com?code=abc" }, + }; + const result = beforeBreadcrumbHandler(breadcrumb as never); + expect(result!.data!.url).toBe( + "https://api.github.com?code=[REDACTED]", + ); + }); + + it("keeps allowed console breadcrumbs", () => { + const prefixes = ["[auth]", "[api]", "[poll]", "[dashboard]", "[settings]"]; + for (const prefix of prefixes) { + const breadcrumb = { + category: "console", + message: `${prefix} some message`, + }; + expect(beforeBreadcrumbHandler(breadcrumb as never)).not.toBeNull(); + } + }); + + it("drops untagged console breadcrumbs", () => { + const breadcrumb = { + category: "console", + message: "random third-party log", + }; + expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + }); + + it("drops console breadcrumbs with empty message", () => { + const breadcrumb = { category: "console", message: "" }; + expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + }); + + it("drops console breadcrumbs with no message", () => { + const breadcrumb = { category: "console" }; + expect(beforeBreadcrumbHandler(breadcrumb as never)).toBeNull(); + }); + + it("passes through non-console, non-navigation breadcrumbs unchanged", () => { + const breadcrumb = { category: "ui.click", message: "button" }; + expect(beforeBreadcrumbHandler(breadcrumb as never)).toBe(breadcrumb); + }); +}); diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index d647f67..dbfe718 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -9,6 +9,7 @@ function makeEnv(overrides: Partial = {}): Env { GITHUB_CLIENT_ID: "test_client_id", GITHUB_CLIENT_SECRET: "test_client_secret", ALLOWED_ORIGIN, + SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", ...overrides, }; } @@ -41,11 +42,48 @@ function makeRequest( // Valid 20-char hex code const VALID_CODE = "a1b2c3d4e5f6a1b2c3d4"; +/** Parse all structured log calls from a console spy, returning {level, entry} tuples. */ +function collectLogs(spies: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +}): Array<{ level: string; entry: Record }> { + const logs: Array<{ level: string; entry: Record }> = []; + for (const [level, spy] of Object.entries(spies)) { + for (const call of spy.mock.calls) { + try { + logs.push({ level, entry: JSON.parse(call[0] as string) }); + } catch { + // non-JSON console output — ignore + } + } + } + return logs; +} + +/** Find the first log entry matching a given event name. */ +function findLog( + logs: Array<{ level: string; entry: Record }>, + event: string +): { level: string; entry: Record } | undefined { + return logs.find((l) => l.entry.event === event); +} + describe("Worker OAuth endpoint", () => { let originalFetch: typeof globalThis.fetch; + let consoleSpy: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; beforeEach(() => { originalFetch = globalThis.fetch; + consoleSpy = { + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; }); afterEach(() => { @@ -397,4 +435,545 @@ describe("Worker OAuth endpoint", () => { expect(assetFetch).toHaveBeenCalledOnce(); expect(res.status).toBe(200); }); + + // ── Structured logging ──────────────────────────────────────────────────── + + describe("Structured logging", () => { + // ── Log format & metadata ───────────────────────────────────────────── + + it("logs are valid JSON with worker identifier and event name", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + expect(logs.length).toBeGreaterThan(0); + for (const { entry } of logs) { + expect(entry.worker).toBe("github-tracker"); + expect(typeof entry.event).toBe("string"); + expect((entry.event as string).length).toBeGreaterThan(0); + } + }); + + it("logs include request metadata (origin, user_agent)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const apiLog = findLog(logs, "api_request"); + expect(apiLog).toBeDefined(); + expect(apiLog!.entry.origin).toBe(ALLOWED_ORIGIN); + }); + + // ── API request & CORS logging ──────────────────────────────────────── + + it("logs api_request for every /api/ request", async () => { + const req = makeRequest("POST", "/api/oauth/token", { body: {} }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const apiLog = findLog(logs, "api_request"); + expect(apiLog).toBeDefined(); + expect(apiLog!.level).toBe("info"); + expect(apiLog!.entry.method).toBe("POST"); + expect(apiLog!.entry.pathname).toBe("/api/oauth/token"); + expect(apiLog!.entry.cors_matched).toBe(true); + }); + + it("logs cors_origin_mismatch when origin does not match", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { + body: { code: VALID_CODE }, + origin: "https://evil.example.com", + }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const corsLog = findLog(logs, "cors_origin_mismatch"); + expect(corsLog).toBeDefined(); + expect(corsLog!.level).toBe("warn"); + expect(corsLog!.entry.request_origin).toBe("https://evil.example.com"); + expect(corsLog!.entry.allowed_origin).toBe(ALLOWED_ORIGIN); + }); + + it("does not log cors_origin_mismatch when origin matches", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { + body: { code: VALID_CODE }, + origin: ALLOWED_ORIGIN, + }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + expect(findLog(logs, "cors_origin_mismatch")).toBeUndefined(); + }); + + it("does not log cors_origin_mismatch when origin header is absent", async () => { + const req = new Request("https://gh.gordoncode.dev/api/health", { method: "GET" }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + expect(findLog(logs, "cors_origin_mismatch")).toBeUndefined(); + }); + + it("logs cors_preflight for OPTIONS /api/oauth/token", async () => { + const req = makeRequest("OPTIONS", "/api/oauth/token", { origin: ALLOWED_ORIGIN }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const preflightLog = findLog(logs, "cors_preflight"); + expect(preflightLog).toBeDefined(); + expect(preflightLog!.level).toBe("info"); + expect(preflightLog!.entry.cors_matched).toBe(true); + }); + + it("does not log api_request for non-API routes (static assets)", async () => { + const req = new Request("https://gh.gordoncode.dev/index.html"); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + expect(findLog(logs, "api_request")).toBeUndefined(); + }); + + it("logs api_not_found for unknown API routes", async () => { + const req = makeRequest("GET", "/api/nonexistent"); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const notFoundLog = findLog(logs, "api_not_found"); + expect(notFoundLog).toBeDefined(); + expect(notFoundLog!.level).toBe("warn"); + expect(notFoundLog!.entry.pathname).toBe("/api/nonexistent"); + }); + + // ── Token exchange lifecycle logging ────────────────────────────────── + + it("logs full success lifecycle: api_request → token_exchange_started → github_oauth_request_sent → token_exchange_succeeded", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + access_token: "ghu_tok", + token_type: "bearer", + scope: "repo read:org", + }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const events = logs.map((l) => l.entry.event); + expect(events).toContain("api_request"); + expect(events).toContain("token_exchange_started"); + expect(events).toContain("github_oauth_request_sent"); + expect(events).toContain("token_exchange_succeeded"); + + const successLog = findLog(logs, "token_exchange_succeeded")!; + expect(successLog.level).toBe("info"); + expect(successLog.entry.scope).toBe("repo read:org"); + expect(successLog.entry.token_type).toBe("bearer"); + expect(successLog.entry.github_status).toBe(200); + }); + + it("logs token_exchange_wrong_method for non-POST requests", async () => { + const req = makeRequest("GET", "/api/oauth/token"); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const methodLog = findLog(logs, "token_exchange_wrong_method"); + expect(methodLog).toBeDefined(); + expect(methodLog!.level).toBe("warn"); + expect(methodLog!.entry.method).toBe("GET"); + }); + + it("logs token_exchange_bad_content_type for wrong Content-Type", async () => { + const req = makeRequest("POST", "/api/oauth/token", { + body: { code: VALID_CODE }, + contentType: "text/plain", + }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const ctLog = findLog(logs, "token_exchange_bad_content_type"); + expect(ctLog).toBeDefined(); + expect(ctLog!.level).toBe("warn"); + expect(ctLog!.entry.content_type).toBe("text/plain"); + }); + + it("logs token_exchange_json_parse_failed for malformed JSON body", async () => { + const req = new Request("https://gh.gordoncode.dev/api/oauth/token", { + method: "POST", + headers: { + "Origin": ALLOWED_ORIGIN, + "Content-Type": "application/json", + }, + body: "not-valid-json{{{", + }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const parseLog = findLog(logs, "token_exchange_json_parse_failed"); + expect(parseLog).toBeDefined(); + expect(parseLog!.level).toBe("warn"); + expect(typeof parseLog!.entry.error).toBe("string"); + }); + + it("logs token_exchange_missing_code when code field is absent", async () => { + const req = makeRequest("POST", "/api/oauth/token", { body: { not_code: "abc" } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const codeLog = findLog(logs, "token_exchange_missing_code"); + expect(codeLog).toBeDefined(); + expect(codeLog!.level).toBe("warn"); + expect(codeLog!.entry.has_code).toBe(false); + }); + + it("logs token_exchange_missing_code when code is not a string", async () => { + const req = makeRequest("POST", "/api/oauth/token", { body: { code: 12345 } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const codeLog = findLog(logs, "token_exchange_missing_code"); + expect(codeLog).toBeDefined(); + expect(codeLog!.entry.has_code).toBe(true); + expect(codeLog!.entry.code_type).toBe("number"); + }); + + it("logs token_exchange_invalid_code_format for regex-failing codes", async () => { + const req = makeRequest("POST", "/api/oauth/token", { body: { code: "abc def!!" } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const fmtLog = findLog(logs, "token_exchange_invalid_code_format"); + expect(fmtLog).toBeDefined(); + expect(fmtLog!.level).toBe("warn"); + expect(fmtLog!.entry.code_length).toBe(9); + expect(fmtLog!.entry.code_has_spaces).toBe(true); + }); + + it("logs github_oauth_fetch_failed when GitHub is unreachable", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError("fetch failed")); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const fetchLog = findLog(logs, "github_oauth_fetch_failed"); + expect(fetchLog).toBeDefined(); + expect(fetchLog!.level).toBe("error"); + expect(fetchLog!.entry.error).toBe("fetch failed"); + expect(fetchLog!.entry.error_name).toBe("TypeError"); + }); + + it("logs github_oauth_error_response with GitHub error details", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + error: "bad_verification_code", + error_description: "The code passed is incorrect or expired.", + error_uri: "https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code", + }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const errLog = findLog(logs, "github_oauth_error_response"); + expect(errLog).toBeDefined(); + expect(errLog!.level).toBe("error"); + expect(errLog!.entry.github_error).toBe("bad_verification_code"); + expect(errLog!.entry.github_error_description).toBe("The code passed is incorrect or expired."); + expect(errLog!.entry.github_error_uri).toContain("docs.github.com"); + expect(errLog!.entry.has_access_token).toBe(false); + }); + + it("logs github_oauth_error_response when access_token is missing from GitHub response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token_type: "bearer" }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const errLog = findLog(logs, "github_oauth_error_response"); + expect(errLog).toBeDefined(); + expect(errLog!.entry.has_access_token).toBe(false); + }); + + // ── Security: logs must NEVER contain secrets ───────────────────────── + + it("logs never contain access tokens, codes, or client secrets", async () => { + const sensitiveToken = "ghu_SuperSecretToken123"; + const sensitiveSecret = "test_client_secret"; + + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + access_token: sensitiveToken, + token_type: "bearer", + scope: "repo", + }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const allLogText = logs.map((l) => JSON.stringify(l.entry)).join("\n"); + + expect(allLogText).not.toContain(sensitiveToken); + expect(allLogText).not.toContain(sensitiveSecret); + expect(allLogText).not.toContain(VALID_CODE); + }); + + it("logs never contain secrets on error paths either", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + error: "bad_verification_code", + error_description: "The code is wrong", + }), { status: 200 }) + ); + + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const allLogText = logs.map((l) => JSON.stringify(l.entry)).join("\n"); + + expect(allLogText).not.toContain(VALID_CODE); + expect(allLogText).not.toContain("test_client_secret"); + }); + }); + + // ── Sentry tunnel ───────────────────────────────────────────────────────── + + describe("Sentry tunnel (/api/error-reporting)", () => { + const SENTRY_HOST = "o123456.ingest.sentry.io"; + const SENTRY_PROJECT_ID = "7890123"; + const VALID_DSN = `https://abc123@${SENTRY_HOST}/${SENTRY_PROJECT_ID}`; + + function makeEnvelope(dsn: string, eventPayload = "{}"): string { + return `${JSON.stringify({ dsn })}\n${JSON.stringify({ type: "event" })}\n${eventPayload}`; + } + + function makeTunnelRequest(body: string): Request { + return new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body, + }); + } + + it("forwards valid envelope to Sentry and returns Sentry's status code", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = mockFetch; + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledOnce(); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`https://${SENTRY_HOST}/api/${SENTRY_PROJECT_ID}/envelope/`); + expect(init.method).toBe("POST"); + }); + + it("rejects GET requests with 405", async () => { + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { method: "GET" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(405); + }); + + it("rejects envelopes with mismatched DSN host", async () => { + const badDsn = `https://abc@evil.ingest.sentry.io/${SENTRY_PROJECT_ID}`; + const req = makeTunnelRequest(makeEnvelope(badDsn)); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + + const logs = collectLogs(consoleSpy); + const mismatchLog = findLog(logs, "sentry_tunnel_dsn_mismatch"); + expect(mismatchLog).toBeDefined(); + expect(mismatchLog!.entry.dsn_host).toBe("evil.ingest.sentry.io"); + }); + + it("rejects envelopes with mismatched DSN project ID", async () => { + const badDsn = `https://abc@${SENTRY_HOST}/9999999`; + const req = makeTunnelRequest(makeEnvelope(badDsn)); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + + const logs = collectLogs(consoleSpy); + const mismatchLog = findLog(logs, "sentry_tunnel_dsn_mismatch"); + expect(mismatchLog).toBeDefined(); + expect(mismatchLog!.entry.dsn_project).toBe("9999999"); + }); + + it("returns 400 for invalid envelope format (no newline)", async () => { + const req = makeTunnelRequest("not an envelope"); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + + const logs = collectLogs(consoleSpy); + const log = findLog(logs, "sentry_tunnel_invalid_envelope"); + expect(log).toBeDefined(); + expect(log!.level).toBe("warn"); + }); + + it("returns 400 for invalid JSON in envelope header", async () => { + const req = makeTunnelRequest("{invalid json\n{}"); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + + const logs = collectLogs(consoleSpy); + const log = findLog(logs, "sentry_tunnel_header_parse_failed"); + expect(log).toBeDefined(); + expect(log!.level).toBe("warn"); + }); + + it("returns 200 for client_report envelopes without DSN", async () => { + const envelope = `${JSON.stringify({ type: "client_report" })}\n{}`; + const req = makeTunnelRequest(envelope); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + + const logs = collectLogs(consoleSpy); + const log = findLog(logs, "sentry_tunnel_no_dsn"); + expect(log).toBeDefined(); + expect(log!.level).toBe("info"); + }); + + it("returns 400 for invalid DSN URL", async () => { + const envelope = `${JSON.stringify({ dsn: "not-a-url" })}\n{}`; + const req = makeTunnelRequest(envelope); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + + const logs = collectLogs(consoleSpy); + const log = findLog(logs, "sentry_tunnel_invalid_dsn"); + expect(log).toBeDefined(); + expect(log!.level).toBe("warn"); + }); + + it("returns 404 when SENTRY_DSN is not configured", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + const res = await worker.fetch(req, makeEnv({ SENTRY_DSN: "" })); + expect(res.status).toBe(404); + }); + + it("returns 502 when Sentry is unreachable", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("connection refused")); + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(502); + + const logs = collectLogs(consoleSpy); + const fetchLog = findLog(logs, "sentry_tunnel_fetch_failed"); + expect(fetchLog).toBeDefined(); + expect(fetchLog!.level).toBe("error"); + }); + + it("logs sentry_tunnel_forwarded on successful proxy", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const fwdLog = findLog(logs, "sentry_tunnel_forwarded"); + expect(fwdLog).toBeDefined(); + expect(fwdLog!.level).toBe("info"); + expect(fwdLog!.entry.sentry_status).toBe(200); + }); + + it("includes security headers on all tunnel responses", async () => { + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { method: "GET" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("never logs the envelope body contents", async () => { + const sensitivePayload = '{"user":{"email":"user@example.com"}}'; + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN, sensitivePayload)); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const allLogText = logs.map((l) => JSON.stringify(l.entry)).join("\n"); + expect(allLogText).not.toContain("user@example.com"); + expect(allLogText).not.toContain(sensitivePayload); + }); + + it("rejects OPTIONS with 405", async () => { + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "OPTIONS", + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(405); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("returns 413 when body exceeds size limit", async () => { + const oversizedBody = "x".repeat(256 * 1024 + 1); + const req = makeTunnelRequest(oversizedBody); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(413); + + const logs = collectLogs(consoleSpy); + const sizeLog = findLog(logs, "sentry_tunnel_payload_too_large"); + expect(sizeLog).toBeDefined(); + expect(sizeLog!.level).toBe("warn"); + expect(sizeLog!.entry.body_length).toBe(256 * 1024 + 1); + }); + + it("allows body at exactly the size limit", async () => { + // Build a valid envelope that is exactly at the limit + const header = JSON.stringify({ dsn: VALID_DSN }); + const padding = "x".repeat(256 * 1024 - header.length - 1); // -1 for newline + const body = `${header}\n${padding}`; + expect(body.length).toBe(256 * 1024); + + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const req = makeTunnelRequest(body); + const res = await worker.fetch(req, makeEnv()); + // Should not be 413 — the body is within limits + expect(res.status).not.toBe(413); + }); + + it("logs cors_origin_mismatch for tunnel requests with wrong origin", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "POST", + headers: { + "Content-Type": "application/x-sentry-envelope", + "Origin": "https://evil.example.com", + }, + body: makeEnvelope(VALID_DSN), + }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const corsLog = findLog(logs, "cors_origin_mismatch"); + expect(corsLog).toBeDefined(); + expect(corsLog!.level).toBe("warn"); + expect(corsLog!.entry.request_origin).toBe("https://evil.example.com"); + }); + }); }); diff --git a/wrangler.toml b/wrangler.toml index e4d95c7..e2a74cf 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -14,6 +14,9 @@ not_found_handling = "single-page-application" pattern = "gh.gordoncode.dev" custom_domain = true +[vars] +SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240" + [observability] enabled = true head_sampling_rate = 1