From 30d7dd35f3e29bb52f00ecfe938a09e52ffbd9be Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 21:23:41 -0400 Subject: [PATCH 01/56] docs(deploy): add WAF rules, Workers Secrets, and local dev documentation --- DEPLOY.md | 111 +++++++++++ src/worker/validation.ts | 87 +++++++++ tests/worker/validation.test.ts | 317 ++++++++++++++++++++++++++++++++ 3 files changed, 515 insertions(+) create mode 100644 src/worker/validation.ts create mode 100644 tests/worker/validation.test.ts diff --git a/DEPLOY.md b/DEPLOY.md index 11d63cfa..d5e97d16 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -95,6 +95,20 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs. +### HTTPS requirement for session cookies + +The `__Host-session` cookie uses the `__Host-` prefix, which browsers **silently reject over HTTP**. To test session cookies locally, use: + +```bash +wrangler dev --local-protocol https +``` + +The self-signed certificate from `--local-protocol https` must be accepted in the browser on first use (click through the "Not Secure" warning or add a security exception). + +### Compatibility flags in local dev + +The `global_fetch_strictly_public` compatibility flag (which blocks Worker subrequests to private/internal IPs) has **no effect** in local `wrangler dev` — workerd ignores it. No local dev workaround is needed for this flag. + ## Deploy Manually ```sh @@ -114,3 +128,100 @@ If you previously deployed with the GitHub App model (HttpOnly cookie refresh to 6. **Delete the old GitHub App** (optional): GitHub → Settings → Developer settings → GitHub Apps → your app → Advanced → Delete The old `POST /api/oauth/refresh` and `POST /api/oauth/logout` endpoints no longer exist and return 404. + +--- + +## WAF Security Rules + +Configure these rules in the Cloudflare dashboard under **Security → WAF**. + +### Custom Rules + +**Rule name:** Block API requests without valid Origin +**Where:** Security → WAF → Custom Rules +**Expression:** +``` +(http.request.uri.path starts_with "/api/") and +not (any(http.request.headers["origin"][*] in {"https://gh.gordoncode.dev"})) and +not (http.request.uri.path eq "/api/csp-report") and +not (http.request.uri.path eq "/api/error-reporting") +``` +**Action:** Block + +**Exemptions:** +- `/api/csp-report` is exempted because browser-generated CSP violation reports (via the Reporting API) may not include an `Origin` header. +- `/api/error-reporting` is exempted for consistency with the CSP tunnel — while the Sentry SDK does include `Origin` in its `fetch()` calls, the exemption keeps both tunnel endpoints treated identically. Both endpoints are low-risk (error reporting only, no sensitive data returned) and have their own validation (DSN check, payload format check). + +**Notes:** +- This uses **1 of the 5 free WAF custom rules** available on all plans. +- Blocks scanners, `curl` without `Origin`, and cross-site browser attacks before the Worker runs (never billed as a Worker request). + +### Rate Limiting Rules + +> **Conditional:** WAF rate limiting rules may require a **Pro plan** or above. If unavailable on your current Cloudflare plan (Free plan), skip this step. The Workers Rate Limiting Binding provides per-session rate limiting instead, and the WAF custom rule (above) still enforces the Origin check layer. + +**Rule name:** Rate limit API proxy endpoints +**Where:** Security → WAF → Rate Limiting Rules +**Matching expression:** +``` +(http.request.uri.path starts_with "/api/") and +(http.request.method ne "OPTIONS") +``` +**Rate:** 60 requests per 10 seconds per IP +**Action:** Block for 60 seconds + +**Notes:** +- `OPTIONS` (CORS preflight) is excluded from counting to avoid blocking legitimate preflight requests. +- Provides globally-consistent rate limiting that runs before the Worker (not per-location like Workers Rate Limiting Binding). + +--- + +## Workers Secrets + +All secrets are set via the `wrangler` CLI and stored in the Cloudflare Worker runtime (never committed to source control). + +### Generating keys + +```bash +# Generate cryptographically strong keys (base64-encoded 32-byte random values): +openssl rand -base64 32 # Run once per key below +``` + +### Setting secrets + +```bash +wrangler secret put SESSION_KEY # HMAC key for session cookies +wrangler secret put SEAL_KEY # AES-256-GCM key for sealed tokens +wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard +``` + +- `SESSION_KEY`: HMAC-SHA256 key used to sign `__Host-session` cookies. Generate with `openssl rand -base64 32`. +- `SEAL_KEY`: AES-256-GCM key used to encrypt Jira/GitLab API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. +- `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key). +- `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key). + +### First deployment + +On initial deployment, set only `SESSION_KEY`, `SEAL_KEY`, and `TURNSTILE_SECRET_KEY`. Do **not** set `SESSION_KEY_PREV` or `SEAL_KEY_PREV` — these are only needed during key rotation after the initial keys are in use. + +### Key rotation + +To rotate a key without invalidating existing sessions/tokens: + +1. Set the `*_PREV` secret to the **current** key value: + ```bash + wrangler secret put SESSION_KEY_PREV # Copy current SESSION_KEY value here first + wrangler secret put SEAL_KEY_PREV # Copy current SEAL_KEY value here first + ``` +2. Generate a new key and update the main secret: + ```bash + openssl rand -base64 32 # generate new value + wrangler secret put SESSION_KEY # update with new value + wrangler secret put SEAL_KEY # update with new value + ``` +3. The Worker will accept tokens signed/sealed with either the current or previous key during the transition window. +4. After all clients have cycled (sessions expire after 8 hours), optionally remove `*_PREV`: + ```bash + wrangler secret delete SESSION_KEY_PREV + wrangler secret delete SEAL_KEY_PREV + ``` diff --git a/src/worker/validation.ts b/src/worker/validation.ts new file mode 100644 index 00000000..085daee6 --- /dev/null +++ b/src/worker/validation.ts @@ -0,0 +1,87 @@ +export type ValidationResult = { ok: true } | { ok: false; code: string; status: number }; + +/** + * Validates that the request Origin header matches the allowed origin exactly. + * Strict equality only — prevents substring spoofing (e.g. evil.gh.gordoncode.dev). + */ +export function validateOrigin(request: Request, allowedOrigin: string): ValidationResult { + const origin = request.headers.get("Origin"); + if (origin !== allowedOrigin) { + return { ok: false, code: "origin_mismatch", status: 403 }; + } + return { ok: true }; +} + +/** + * Validates the Sec-Fetch-Site header for fetch metadata resource isolation policy. + * - "same-origin" → allowed (from our SPA) + * - absent → allowed (legacy browsers without Fetch Metadata support) + * - anything else → rejected (cross-site, same-site, or direct navigation) + */ +export function validateFetchMetadata(request: Request): ValidationResult { + const secFetchSite = request.headers.get("Sec-Fetch-Site"); + if (secFetchSite === null || secFetchSite === "same-origin") { + return { ok: true }; + } + return { ok: false, code: "cross_site_request", status: 403 }; +} + +/** + * Validates the X-Requested-With custom header. + * Requires value "fetch" — triggers CORS preflight for cross-origin requests, + * blocking cross-origin form submissions and scripted attacks. + */ +export function validateCustomHeader(request: Request): ValidationResult { + const value = request.headers.get("X-Requested-With"); + if (value !== "fetch") { + return { ok: false, code: "missing_csrf_header", status: 403 }; + } + return { ok: true }; +} + +/** + * Validates the Content-Type header starts with the expected media type. + * Case-insensitive comparison. + */ +export function validateContentType(request: Request, expected: string): ValidationResult { + const contentType = request.headers.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().startsWith(expected.toLowerCase())) { + return { ok: false, code: "invalid_content_type", status: 415 }; + } + return { ok: true }; +} + +const METHODS_REQUIRING_CONTENT_TYPE = new Set(["POST", "PUT", "PATCH"]); + +/** + * Composite validator that runs all checks in sequence for proxy routes. + * Short-circuits on first failure. + * + * Checks run in order: + * 1. Origin validation (always) + * 2. Sec-Fetch-Site validation (always) + * 3. Custom X-Requested-With header (always) + * 4. Content-Type (POST/PUT/PATCH only — skipped for GET/HEAD/DELETE/OPTIONS) + */ +export function validateProxyRequest( + request: Request, + allowedOrigin: string, + options?: { expectedContentType?: string } +): ValidationResult { + const originResult = validateOrigin(request, allowedOrigin); + if (!originResult.ok) return originResult; + + const fetchMetaResult = validateFetchMetadata(request); + if (!fetchMetaResult.ok) return fetchMetaResult; + + const customHeaderResult = validateCustomHeader(request); + if (!customHeaderResult.ok) return customHeaderResult; + + if (METHODS_REQUIRING_CONTENT_TYPE.has(request.method)) { + const expected = options?.expectedContentType ?? "application/json"; + const contentTypeResult = validateContentType(request, expected); + if (!contentTypeResult.ok) return contentTypeResult; + } + + return { ok: true }; +} diff --git a/tests/worker/validation.test.ts b/tests/worker/validation.test.ts new file mode 100644 index 00000000..7ed768fe --- /dev/null +++ b/tests/worker/validation.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from "vitest"; +import { + validateOrigin, + validateFetchMetadata, + validateCustomHeader, + validateContentType, + validateProxyRequest, +} from "../../src/worker/validation"; + +const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; + +function makeRequest( + options: { + method?: string; + headers?: Record; + } = {} +): Request { + return new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: options.method ?? "POST", + headers: options.headers ?? {}, + }); +} + +// ── validateOrigin ────────────────────────────────────────────────────────── + +describe("validateOrigin", () => { + it("returns ok when Origin matches exactly", () => { + const req = makeRequest({ headers: { Origin: ALLOWED_ORIGIN } }); + expect(validateOrigin(req, ALLOWED_ORIGIN)).toEqual({ ok: true }); + }); + + it("returns origin_mismatch for a different origin", () => { + const req = makeRequest({ headers: { Origin: "https://evil.com" } }); + const result = validateOrigin(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); + + it("returns origin_mismatch when no Origin header", () => { + const req = makeRequest({}); + const result = validateOrigin(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); + + it("rejects substring attack — evil.com subdomain of allowed origin", () => { + const req = makeRequest({ headers: { Origin: "https://gh.gordoncode.dev.evil.com" } }); + const result = validateOrigin(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); + + it("rejects prefix spoofing — allowed origin as prefix of evil domain", () => { + const req = makeRequest({ headers: { Origin: "https://gh.gordoncode.dev.com" } }); + const result = validateOrigin(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); +}); + +// ── validateFetchMetadata ─────────────────────────────────────────────────── + +describe("validateFetchMetadata", () => { + it("returns ok for same-origin", () => { + const req = makeRequest({ headers: { "Sec-Fetch-Site": "same-origin" } }); + expect(validateFetchMetadata(req)).toEqual({ ok: true }); + }); + + it("returns ok when Sec-Fetch-Site header is absent (legacy browser)", () => { + const req = makeRequest({}); + expect(validateFetchMetadata(req)).toEqual({ ok: true }); + }); + + it("rejects cross-site", () => { + const req = makeRequest({ headers: { "Sec-Fetch-Site": "cross-site" } }); + const result = validateFetchMetadata(req); + expect(result).toEqual({ ok: false, code: "cross_site_request", status: 403 }); + }); + + it("rejects same-site", () => { + const req = makeRequest({ headers: { "Sec-Fetch-Site": "same-site" } }); + const result = validateFetchMetadata(req); + expect(result).toEqual({ ok: false, code: "cross_site_request", status: 403 }); + }); + + it("rejects none (direct navigation not allowed on API routes)", () => { + const req = makeRequest({ headers: { "Sec-Fetch-Site": "none" } }); + const result = validateFetchMetadata(req); + expect(result).toEqual({ ok: false, code: "cross_site_request", status: 403 }); + }); +}); + +// ── validateCustomHeader ──────────────────────────────────────────────────── + +describe("validateCustomHeader", () => { + it("returns ok for X-Requested-With: fetch", () => { + const req = makeRequest({ headers: { "X-Requested-With": "fetch" } }); + expect(validateCustomHeader(req)).toEqual({ ok: true }); + }); + + it("rejects XMLHttpRequest value", () => { + const req = makeRequest({ headers: { "X-Requested-With": "XMLHttpRequest" } }); + const result = validateCustomHeader(req); + expect(result).toEqual({ ok: false, code: "missing_csrf_header", status: 403 }); + }); + + it("rejects missing header", () => { + const req = makeRequest({}); + const result = validateCustomHeader(req); + expect(result).toEqual({ ok: false, code: "missing_csrf_header", status: 403 }); + }); + + it("rejects empty string value", () => { + const req = makeRequest({ headers: { "X-Requested-With": "" } }); + const result = validateCustomHeader(req); + expect(result).toEqual({ ok: false, code: "missing_csrf_header", status: 403 }); + }); +}); + +// ── validateContentType ───────────────────────────────────────────────────── + +describe("validateContentType", () => { + it("returns ok for exact match", () => { + const req = makeRequest({ headers: { "Content-Type": "application/json" } }); + expect(validateContentType(req, "application/json")).toEqual({ ok: true }); + }); + + it("returns ok when Content-Type includes charset suffix", () => { + const req = makeRequest({ headers: { "Content-Type": "application/json; charset=utf-8" } }); + expect(validateContentType(req, "application/json")).toEqual({ ok: true }); + }); + + it("is case-insensitive", () => { + const req = makeRequest({ headers: { "Content-Type": "Application/JSON" } }); + expect(validateContentType(req, "application/json")).toEqual({ ok: true }); + }); + + it("rejects text/plain", () => { + const req = makeRequest({ headers: { "Content-Type": "text/plain" } }); + const result = validateContentType(req, "application/json"); + expect(result).toEqual({ ok: false, code: "invalid_content_type", status: 415 }); + }); + + it("rejects missing Content-Type", () => { + const req = makeRequest({}); + const result = validateContentType(req, "application/json"); + expect(result).toEqual({ ok: false, code: "invalid_content_type", status: 415 }); + }); +}); + +// ── validateProxyRequest ──────────────────────────────────────────────────── + +describe("validateProxyRequest", () => { + function makeValidPostRequest(extra: Record = {}): Request { + return makeRequest({ + method: "POST", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "application/json", + ...extra, + }, + }); + } + + it("returns ok for POST request with all valid headers", () => { + const req = makeValidPostRequest(); + expect(validateProxyRequest(req, ALLOWED_ORIGIN)).toEqual({ ok: true }); + }); + + it("returns ok for GET request without Content-Type (skipped)", () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/data", { + method: "GET", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + // No Content-Type + }, + }); + expect(validateProxyRequest(req, ALLOWED_ORIGIN)).toEqual({ ok: true }); + }); + + it("returns ok for HEAD request without Content-Type (skipped)", () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/data", { + method: "HEAD", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + }, + }); + expect(validateProxyRequest(req, ALLOWED_ORIGIN)).toEqual({ ok: true }); + }); + + it("returns ok for DELETE request without Content-Type (skipped)", () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/data", { + method: "DELETE", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + }, + }); + expect(validateProxyRequest(req, ALLOWED_ORIGIN)).toEqual({ ok: true }); + }); + + it("fails with origin_mismatch when Origin missing (short-circuits)", () => { + const req = makeRequest({ + method: "POST", + headers: { + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); + + it("fails with cross_site_request when Sec-Fetch-Site is cross-site", () => { + const req = makeRequest({ + method: "POST", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "cross-site", + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "cross_site_request", status: 403 }); + }); + + it("fails with missing_csrf_header when X-Requested-With is absent", () => { + const req = makeRequest({ + method: "POST", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "missing_csrf_header", status: 403 }); + }); + + it("fails with invalid_content_type for PUT with wrong Content-Type", () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/data", { + method: "PUT", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "text/plain", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "invalid_content_type", status: 415 }); + }); + + it("fails with invalid_content_type for PATCH with wrong Content-Type", () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/data", { + method: "PATCH", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "text/plain", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "invalid_content_type", status: 415 }); + }); + + it("uses custom expectedContentType when provided", () => { + const req = makeRequest({ + method: "POST", + headers: { + Origin: ALLOWED_ORIGIN, + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN, { + expectedContentType: "application/x-www-form-urlencoded", + }); + expect(result).toEqual({ ok: true }); + }); + + it("short-circuits on first failure (origin checked before fetch metadata)", () => { + // Both Origin and Sec-Fetch-Site are wrong — should fail on origin_mismatch + const req = makeRequest({ + method: "POST", + headers: { + Origin: "https://evil.com", + "Sec-Fetch-Site": "cross-site", + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); + + it("rejects origin substring attack through proxy validation", () => { + const req = makeRequest({ + method: "POST", + headers: { + Origin: "https://gh.gordoncode.dev.evil.com", + "Sec-Fetch-Site": "same-origin", + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + }); + const result = validateProxyRequest(req, ALLOWED_ORIGIN); + expect(result).toEqual({ ok: false, code: "origin_mismatch", status: 403 }); + }); +}); From c7745fc8a1ff47513d05b488238940405f4bb32f Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 21:26:11 -0400 Subject: [PATCH 02/56] feat(worker): add crypto module for sealed tokens and session signing --- src/worker/crypto.ts | 226 ++++++++++++++++++++++++++++ tests/worker/crypto.test.ts | 288 ++++++++++++++++++++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 src/worker/crypto.ts create mode 100644 tests/worker/crypto.test.ts diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts new file mode 100644 index 00000000..8f9fa905 --- /dev/null +++ b/src/worker/crypto.ts @@ -0,0 +1,226 @@ +export interface CryptoEnv { + SEAL_KEY: string; // base64-encoded 32-byte AES-256-GCM key + SEAL_KEY_PREV?: string; // previous key for rotation +} + +// ── Base64url utilities ──────────────────────────────────────────────────── + +export function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +export function fromBase64Url(str: string): Uint8Array { + const padded = str.replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (padded.length % 4)) % 4; + const base64 = padded + "=".repeat(padding); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// ── HKDF key derivation ──────────────────────────────────────────────────── + +/** + * Derives a CryptoKey from a base64-encoded secret using HKDF. + * - usage "encrypt" → AES-256-GCM key + * - usage "sign" → HMAC-SHA256 key + * + * The info parameter MUST include a purpose string for token audience binding + * (SC-8). Pass e.g. "aes-gcm-key:" or "session-hmac" so keys derived + * for different purposes are cryptographically isolated. + */ +export async function deriveKey( + secret: string, + salt: string, + info: string, + usage: "encrypt" | "sign" +): Promise { + const secretBytes = fromBase64Url(secret); + const keyMaterial = await crypto.subtle.importKey( + "raw", + secretBytes.buffer as ArrayBuffer, + { name: "HKDF" }, + false, + ["deriveKey"] + ); + + const saltBytes = new TextEncoder().encode(salt); + const infoBytes = new TextEncoder().encode(info); + + if (usage === "encrypt") { + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: saltBytes, info: infoBytes }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } else { + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: saltBytes, info: infoBytes }, + keyMaterial, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); + } +} + +// ── Sealed-token encryption ──────────────────────────────────────────────── +// Byte layout: [version:1][iv:12][ciphertext+tag:N] +// version = 0x01 (reserved for future format changes) + +const SEAL_VERSION = 0x01; +const SEAL_SALT = "sealed-token-v1"; + +/** + * Encrypts a plaintext string with AES-256-GCM. + * Returns a base64url-encoded sealed token. + */ +export async function sealToken( + plaintext: string, + key: CryptoKey +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintextBytes = new TextEncoder().encode(plaintext); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + plaintextBytes + ); + + const ciphertextBytes = new Uint8Array(ciphertext); + const result = new Uint8Array(1 + 12 + ciphertextBytes.length); + result[0] = SEAL_VERSION; + result.set(iv, 1); + result.set(ciphertextBytes, 13); + + return toBase64Url(result); +} + +/** + * Decrypts a sealed token produced by sealToken. + * Returns null on any failure (wrong key, tampered ciphertext, bad version). + */ +export async function unsealToken( + sealed: string, + key: CryptoKey +): Promise { + let bytes: Uint8Array; + try { + bytes = fromBase64Url(sealed); + } catch { + return null; + } + + if (bytes.length < 1 + 12 + 16) return null; // too short to be valid + if (bytes[0] !== SEAL_VERSION) return null; + + const iv = bytes.slice(1, 13); + const ciphertext = bytes.slice(13); + + try { + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext + ); + return new TextDecoder().decode(plaintext); + } catch { + return null; + } +} + +/** + * Unseals a token, falling back to prevKey if currentKey fails. + * Both salt and info must match the values used during sealing. + * SC-8: info MUST include a purpose string for token audience binding. + */ +export async function unsealTokenWithRotation( + sealed: string, + currentKey: string, + prevKey: string | undefined, + salt: string, + info: string +): Promise { + const current = await deriveKey(currentKey, salt, info, "encrypt"); + const result = await unsealToken(sealed, current); + if (result !== null) return result; + + if (prevKey !== undefined) { + const prev = await deriveKey(prevKey, salt, info, "encrypt"); + return unsealToken(sealed, prev); + } + + return null; +} + +// ── HMAC session signing ─────────────────────────────────────────────────── + +/** + * Signs a payload string with HMAC-SHA256. + * Returns a base64url-encoded signature. + */ +export async function signSession( + payload: string, + key: CryptoKey +): Promise { + const payloadBytes = new TextEncoder().encode(payload); + const signature = await crypto.subtle.sign("HMAC", key, payloadBytes); + return toBase64Url(new Uint8Array(signature)); +} + +/** + * Verifies an HMAC-SHA256 signature. Uses crypto.subtle.verify (timing-safe). + */ +export async function verifySession( + payload: string, + signature: string, + key: CryptoKey +): Promise { + let sigBytes: Uint8Array; + try { + sigBytes = fromBase64Url(signature); + } catch { + return false; + } + + const payloadBytes = new TextEncoder().encode(payload); + try { + return await crypto.subtle.verify("HMAC", key, sigBytes.buffer as ArrayBuffer, payloadBytes); + } catch { + return false; + } +} + +/** + * Verifies a session signature, falling back to prevKey if currentKey fails. + * Both salt and info must match the values used during signing (issueSession). + */ +export async function verifySessionWithRotation( + payload: string, + signature: string, + currentKey: string, + prevKey: string | undefined, + salt: string, + info: string +): Promise { + const current = await deriveKey(currentKey, salt, info, "sign"); + if (await verifySession(payload, signature, current)) return true; + + if (prevKey !== undefined) { + const prev = await deriveKey(prevKey, salt, info, "sign"); + return verifySession(payload, signature, prev); + } + + return false; +} + +export { SEAL_SALT }; diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts new file mode 100644 index 00000000..b2c72964 --- /dev/null +++ b/tests/worker/crypto.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect } from "vitest"; +import { + toBase64Url, + fromBase64Url, + deriveKey, + sealToken, + unsealToken, + unsealTokenWithRotation, + signSession, + verifySession, + verifySessionWithRotation, +} from "../../src/worker/crypto"; + +// Stable base64url-encoded 32-byte test keys (not real secrets) +const KEY_A = btoa("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +const KEY_B = btoa("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + +describe("toBase64Url / fromBase64Url", () => { + it("round-trips arbitrary bytes", () => { + const original = new Uint8Array([0, 1, 2, 127, 128, 254, 255]); + const encoded = toBase64Url(original); + const decoded = fromBase64Url(encoded); + expect(decoded).toEqual(original); + }); + + it("round-trips empty bytes", () => { + const original = new Uint8Array(0); + expect(fromBase64Url(toBase64Url(original))).toEqual(original); + }); + + it("produces no +, /, or = characters", () => { + // Use bytes that produce all three problematic chars in standard base64 + for (let i = 0; i < 256; i++) { + const bytes = new Uint8Array([i, i + 1, i + 2]); + const encoded = toBase64Url(bytes); + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + expect(encoded).not.toContain("="); + } + }); + + it("handles 1-byte input (needs padding)", () => { + const bytes = new Uint8Array([0xab]); + const encoded = toBase64Url(bytes); + expect(fromBase64Url(encoded)).toEqual(bytes); + }); +}); + +describe("deriveKey", () => { + it("returns AES-GCM key for encrypt usage", async () => { + const key = await deriveKey(KEY_A, "salt", "info", "encrypt"); + expect(key.algorithm.name).toBe("AES-GCM"); + expect(key.usages).toContain("encrypt"); + expect(key.usages).toContain("decrypt"); + expect(key.extractable).toBe(false); + }); + + it("returns HMAC key for sign usage", async () => { + const key = await deriveKey(KEY_A, "salt", "info", "sign"); + expect(key.algorithm.name).toBe("HMAC"); + expect(key.usages).toContain("sign"); + expect(key.usages).toContain("verify"); + expect(key.extractable).toBe(false); + }); + + it("produces consistent output from same inputs", async () => { + // Two keys derived with same params should encrypt/decrypt interchangeably + const key1 = await deriveKey(KEY_A, "salt", "info", "sign"); + const key2 = await deriveKey(KEY_A, "salt", "info", "sign"); + const sig1 = await signSession("payload", key1); + const sig2 = await signSession("payload", key2); + expect(sig1).toBe(sig2); + }); + + it("produces different keys for different salt", async () => { + const key1 = await deriveKey(KEY_A, "salt-a", "info", "sign"); + const key2 = await deriveKey(KEY_A, "salt-b", "info", "sign"); + const sig1 = await signSession("payload", key1); + const sig2 = await signSession("payload", key2); + expect(sig1).not.toBe(sig2); + }); + + it("produces different keys for different info", async () => { + const key1 = await deriveKey(KEY_A, "salt", "info-a", "sign"); + const key2 = await deriveKey(KEY_A, "salt", "info-b", "sign"); + const sig1 = await signSession("payload", key1); + const sig2 = await signSession("payload", key2); + expect(sig1).not.toBe(sig2); + }); +}); + +describe("sealToken / unsealToken", () => { + it("round-trips a simple string", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("ghp_abc123", key); + const unsealed = await unsealToken(sealed, key); + expect(unsealed).toBe("ghp_abc123"); + }); + + it("round-trips empty string", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("", key); + expect(await unsealToken(sealed, key)).toBe(""); + }); + + it("round-trips a 1KB payload", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const large = "x".repeat(1024); + const sealed = await sealToken(large, key); + expect(await unsealToken(sealed, key)).toBe(large); + }); + + it("produces different ciphertext on each call (random IV)", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const s1 = await sealToken("same-payload", key); + const s2 = await sealToken("same-payload", key); + expect(s1).not.toBe(s2); + }); + + it("returns null for garbage input", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + expect(await unsealToken("garbage!!!", key)).toBeNull(); + }); + + it("returns null for wrong key", async () => { + const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const keyB = await deriveKey(KEY_B, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("secret", keyA); + expect(await unsealToken(sealed, keyB)).toBeNull(); + }); + + it("returns null for tampered ciphertext (GCM auth tag fails)", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("secret", key); + // Flip the last character to tamper with the ciphertext/tag + const tampered = sealed.slice(0, -1) + (sealed.endsWith("a") ? "b" : "a"); + expect(await unsealToken(tampered, key)).toBeNull(); + }); + + it("returns null for wrong version byte", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("secret", key); + const bytes = fromBase64Url(sealed); + bytes[0] = 0x02; // wrong version + expect(await unsealToken(toBase64Url(bytes), key)).toBeNull(); + }); + + it("returns null for too-short input", async () => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + // 1 + 12 + 15 = 28 bytes (one short of minimum valid ciphertext with 16-byte tag) + const short = new Uint8Array(28); + short[0] = 0x01; + expect(await unsealToken(toBase64Url(short), key)).toBeNull(); + }); +}); + +describe("unsealTokenWithRotation", () => { + it("unseals with current key", async () => { + const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("value", keyA); + const result = await unsealTokenWithRotation( + sealed, + KEY_A, + undefined, + "sealed-token-v1", + "aes-gcm-key" + ); + expect(result).toBe("value"); + }); + + it("falls back to prevKey when currentKey fails", async () => { + const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("value", keyA); + // Sealed with A, try currentKey=B, prevKey=A + const result = await unsealTokenWithRotation( + sealed, + KEY_B, + KEY_A, + "sealed-token-v1", + "aes-gcm-key" + ); + expect(result).toBe("value"); + }); + + it("returns null when prevKey is undefined and currentKey fails", async () => { + const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("value", keyA); + const result = await unsealTokenWithRotation( + sealed, + KEY_B, + undefined, + "sealed-token-v1", + "aes-gcm-key" + ); + expect(result).toBeNull(); + }); + + it("returns null when both keys fail", async () => { + const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken("value", keyA); + const KEY_C = btoa("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + const result = await unsealTokenWithRotation( + sealed, + KEY_B, + KEY_C, + "sealed-token-v1", + "aes-gcm-key" + ); + expect(result).toBeNull(); + }); +}); + +describe("signSession / verifySession", () => { + it("round-trip: sign then verify returns true", async () => { + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("payload-data", key); + expect(await verifySession("payload-data", sig, key)).toBe(true); + }); + + it("returns false for wrong signature", async () => { + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + expect(await verifySession("payload-data", "wrong-sig", key)).toBe(false); + }); + + it("returns false for tampered payload", async () => { + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("original-payload", key); + expect(await verifySession("tampered-payload", sig, key)).toBe(false); + }); + + it("returns false for wrong key", async () => { + const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const keyB = await deriveKey(KEY_B, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("payload", keyA); + expect(await verifySession("payload", sig, keyB)).toBe(false); + }); + + it("returns false for invalid base64 signature", async () => { + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + expect(await verifySession("payload", "!!!invalid!!!", key)).toBe(false); + }); +}); + +describe("verifySessionWithRotation", () => { + it("verifies with current key", async () => { + const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("data", keyA); + const result = await verifySessionWithRotation( + "data", + sig, + KEY_A, + undefined, + "github-tracker-session-v1", + "session-hmac" + ); + expect(result).toBe(true); + }); + + it("falls back to prevKey when current key fails", async () => { + const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("data", keyA); + // Signed with A, try currentKey=B, prevKey=A + const result = await verifySessionWithRotation( + "data", + sig, + KEY_B, + KEY_A, + "github-tracker-session-v1", + "session-hmac" + ); + expect(result).toBe(true); + }); + + it("returns false when both keys fail", async () => { + const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("data", keyA); + const KEY_C = btoa("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + const result = await verifySessionWithRotation( + "data", + sig, + KEY_B, + KEY_C, + "github-tracker-session-v1", + "session-hmac" + ); + expect(result).toBe(false); + }); +}); From 94e16dd95d2aa1a7aae8d7a83839c4a5b151e4be Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 21:29:25 -0400 Subject: [PATCH 03/56] feat(worker): adds session cookie infrastructure --- src/worker/session.ts | 148 +++++++++++++++++ src/worker/turnstile.ts | 63 ++++++++ tests/worker/crypto.test.ts | 7 +- tests/worker/session.test.ts | 285 +++++++++++++++++++++++++++++++++ tests/worker/turnstile.test.ts | 192 ++++++++++++++++++++++ 5 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 src/worker/session.ts create mode 100644 src/worker/turnstile.ts create mode 100644 tests/worker/session.test.ts create mode 100644 tests/worker/turnstile.test.ts diff --git a/src/worker/session.ts b/src/worker/session.ts new file mode 100644 index 00000000..b316bf5c --- /dev/null +++ b/src/worker/session.ts @@ -0,0 +1,148 @@ +// Session cookie infrastructure for proxy request binding. +// +// SDR-001: The __Host-session cookie is for rate-limiting binding ONLY, +// NOT authentication. It proves a browser initiated the request; it does +// not prove who the user is. API tokens are managed separately via sealed +// blobs in localStorage. +// +// Local dev note: The __Host- prefix requires HTTPS. Use +// `wrangler dev --local-protocol https` to test session cookies locally. +// See DEPLOY.md "## Local Development" for details. + +import { + deriveKey, + signSession, + verifySessionWithRotation, +} from "./crypto"; + +export interface SessionEnv { + SESSION_KEY: string; + SESSION_KEY_PREV?: string; +} + +export interface SessionPayload { + sid: string; // random session ID (crypto.randomUUID()) + iat: number; // issued-at (epoch seconds) + exp: number; // expiry (epoch seconds) +} + +const SESSION_COOKIE_NAME = "__Host-session"; +const SESSION_HMAC_SALT = "github-tracker-session-v1"; +const SESSION_HMAC_INFO = "session-hmac"; +const SESSION_MAX_AGE = 28800; // 8 hours in seconds + +/** + * Issues a new signed session cookie. + * Returns the Set-Cookie header value and the sessionId for rate-limiting. + */ +export async function issueSession( + env: SessionEnv +): Promise<{ cookie: string; sessionId: string }> { + const now = Math.floor(Date.now() / 1000); + const payload: SessionPayload = { + sid: crypto.randomUUID(), + iat: now, + exp: now + SESSION_MAX_AGE, + }; + + const json = JSON.stringify(payload); + const hmacKey = await deriveKey( + env.SESSION_KEY, + SESSION_HMAC_SALT, + SESSION_HMAC_INFO, + "sign" + ); + const signature = await signSession(json, hmacKey); + + // base64url(JSON(payload)).base64url(HMAC-SHA256(JSON(payload))) + const encodedPayload = btoa(json) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + const cookieValue = `${encodedPayload}.${signature}`; + const cookie = `${SESSION_COOKIE_NAME}=${cookieValue}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}`; + + return { cookie, sessionId: payload.sid }; +} + +/** + * Parses and verifies a session from the Cookie header string. + * Returns null if missing, invalid, tampered, or expired. Never throws. + */ +export async function parseSession( + cookieHeader: string | null, + env: SessionEnv +): Promise { + if (!cookieHeader) return null; + + try { + // Extract the __Host-session cookie value from the Cookie header + const cookies = cookieHeader.split(";").map((c) => c.trim()); + const entry = cookies.find((c) => + c.startsWith(`${SESSION_COOKIE_NAME}=`) + ); + if (!entry) return null; + + const cookieValue = entry.slice(`${SESSION_COOKIE_NAME}=`.length); + const dotIndex = cookieValue.lastIndexOf("."); + if (dotIndex === -1) return null; + + const encodedPayload = cookieValue.slice(0, dotIndex); + const signature = cookieValue.slice(dotIndex + 1); + + // Decode and parse the payload + const paddedPayload = + encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (paddedPayload.length % 4)) % 4; + const json = atob(paddedPayload + "=".repeat(padding)); + const payload = JSON.parse(json) as SessionPayload; + + // Verify HMAC signature (rotation-aware) + const valid = await verifySessionWithRotation( + json, + signature, + env.SESSION_KEY, + env.SESSION_KEY_PREV, + SESSION_HMAC_SALT, + SESSION_HMAC_INFO + ); + if (!valid) return null; + + // Check expiry + if (payload.exp <= Math.floor(Date.now() / 1000)) return null; + + return payload; + } catch { + return null; + } +} + +/** + * Returns a Set-Cookie header value that clears the session cookie. + */ +export function clearSession(): string { + return `${SESSION_COOKIE_NAME}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0`; +} + +/** + * Returns the existing session ID if valid, or issues a new session. + * Never throws — all error paths return a value. + * Callers must attach setCookie to their response if present. + */ +export async function ensureSession( + request: Request, + env: SessionEnv +): Promise<{ sessionId: string; setCookie?: string }> { + const cookieHeader = request.headers.get("Cookie"); + const existing = await parseSession(cookieHeader, env); + + if (existing) { + return { sessionId: existing.sid }; + } + + const { cookie, sessionId } = await issueSession(env); + return { sessionId, setCookie: cookie }; +} + +export { SESSION_HMAC_SALT, SESSION_HMAC_INFO }; diff --git a/src/worker/turnstile.ts b/src/worker/turnstile.ts new file mode 100644 index 00000000..aa29abfe --- /dev/null +++ b/src/worker/turnstile.ts @@ -0,0 +1,63 @@ +export interface TurnstileEnv { + TURNSTILE_SECRET_KEY: string; +} + +interface TurnstileResponse { + success: boolean; + "error-codes"?: string[]; +} + +/** + * Verifies a Turnstile challenge token by calling the Cloudflare siteverify API. + * + * - Uses redirect: "error" to prevent SSRF via redirect chaining. + * - Includes idempotency_key to deduplicate processing on network-timeout retries. + * Note: tokens are single-use — once verified, the token is consumed. Do NOT + * retry this function on failure; return 403 and require the SPA to get a new token. + * - Omits remoteip field when ip is null. + */ +export async function verifyTurnstile( + token: string, + ip: string | null, + env: TurnstileEnv +): Promise<{ success: boolean; errorCodes?: string[] }> { + const body = new FormData(); + body.append("secret", env.TURNSTILE_SECRET_KEY); + body.append("response", token); + if (ip !== null) { + body.append("remoteip", ip); + } + body.append("idempotency_key", crypto.randomUUID()); + + let resp: Response; + try { + resp = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + body, + redirect: "error", + }); + } catch { + return { success: false, errorCodes: ["network-error"] }; + } + + let data: TurnstileResponse; + try { + data = (await resp.json()) as TurnstileResponse; + } catch { + return { success: false, errorCodes: ["network-error"] }; + } + + if (data.success) { + return { success: true }; + } + + return { success: false, errorCodes: data["error-codes"] ?? [] }; +} + +/** + * Extracts the Turnstile response token from the cf-turnstile-response request header. + * Returns null if the header is absent. + */ +export function extractTurnstileToken(request: Request): string | null { + return request.headers.get("cf-turnstile-response"); +} diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index b2c72964..6726552e 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -132,9 +132,10 @@ describe("sealToken / unsealToken", () => { it("returns null for tampered ciphertext (GCM auth tag fails)", async () => { const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); const sealed = await sealToken("secret", key); - // Flip the last character to tamper with the ciphertext/tag - const tampered = sealed.slice(0, -1) + (sealed.endsWith("a") ? "b" : "a"); - expect(await unsealToken(tampered, key)).toBeNull(); + // Flip a byte in the ciphertext portion (byte 14+) to fail GCM auth tag + const bytes = fromBase64Url(sealed); + bytes[14] ^= 0xff; // XOR to guarantee a change + expect(await unsealToken(toBase64Url(bytes), key)).toBeNull(); }); it("returns null for wrong version byte", async () => { diff --git a/tests/worker/session.test.ts b/tests/worker/session.test.ts new file mode 100644 index 00000000..b03aa472 --- /dev/null +++ b/tests/worker/session.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi } from "vitest"; +import { + issueSession, + parseSession, + clearSession, + ensureSession, + type SessionEnv, +} from "../../src/worker/session"; + +// Stable base64url-encoded test keys (not real secrets) +const KEY_A = + "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=".replace(/=/g, ""); +const KEY_B = + "QUJBQUJBQUJBQUJBQUJBQUJBQUJBQUJBQUJBQUJBQUE=".replace(/=/g, ""); + +function makeEnv(overrides: Partial = {}): SessionEnv { + return { + SESSION_KEY: KEY_A, + ...overrides, + }; +} + +describe("issueSession", () => { + it("returns a cookie string starting with __Host-session=", async () => { + const { cookie } = await issueSession(makeEnv()); + expect(cookie).toContain("__Host-session="); + }); + + it("cookie contains two dot-separated base64url segments", async () => { + const { cookie } = await issueSession(makeEnv()); + const value = cookie.split(";")[0].split("=").slice(1).join("="); + const parts = value.split("."); + // payload.signature (signature itself contains no dots) + expect(parts.length).toBeGreaterThanOrEqual(2); + // payload and signature are non-empty + expect(parts[0].length).toBeGreaterThan(0); + expect(parts[parts.length - 1].length).toBeGreaterThan(0); + }); + + it("cookie contains required attributes", async () => { + const { cookie } = await issueSession(makeEnv()); + expect(cookie).toContain("Path=/"); + expect(cookie).toContain("Secure"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + expect(cookie).toContain("Max-Age=28800"); + }); + + it("returns a sessionId (UUID format)", async () => { + const { sessionId } = await issueSession(makeEnv()); + expect(sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + }); + + it("each call produces a unique sessionId", async () => { + const env = makeEnv(); + const { sessionId: s1 } = await issueSession(env); + const { sessionId: s2 } = await issueSession(env); + expect(s1).not.toBe(s2); + }); +}); + +describe("parseSession", () => { + it("round-trips: issue then parse returns matching payload", async () => { + const env = makeEnv(); + const { cookie, sessionId } = await issueSession(env); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const parsed = await parseSession( + `__Host-session=${cookieValue}`, + env + ); + expect(parsed).not.toBeNull(); + expect(parsed!.sid).toBe(sessionId); + }); + + it("returns null for null cookie header", async () => { + expect(await parseSession(null, makeEnv())).toBeNull(); + }); + + it("returns null for empty cookie header", async () => { + expect(await parseSession("", makeEnv())).toBeNull(); + }); + + it("returns null when cookie name does not match (__Host- prefix required)", async () => { + const env = makeEnv(); + const { cookie } = await issueSession(env); + // Strip __Host- prefix from cookie name + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const result = await parseSession(`session=${cookieValue}`, env); + expect(result).toBeNull(); + }); + + it("returns null for tampered payload (signature mismatch)", async () => { + const env = makeEnv(); + const { cookie } = await issueSession(env); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const dotIndex = cookieValue.lastIndexOf("."); + const encodedPayload = cookieValue.slice(0, dotIndex); + const signature = cookieValue.slice(dotIndex + 1); + + // Tamper: modify last char of encoded payload + const tampered = + encodedPayload.slice(0, -1) + + (encodedPayload.endsWith("a") ? "b" : "a"); + const result = await parseSession( + `__Host-session=${tampered}.${signature}`, + env + ); + expect(result).toBeNull(); + }); + + it("returns null for tampered signature", async () => { + const env = makeEnv(); + const { cookie } = await issueSession(env); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const dotIndex = cookieValue.lastIndexOf("."); + const encodedPayload = cookieValue.slice(0, dotIndex); + + const result = await parseSession( + `__Host-session=${encodedPayload}.invalidsignature`, + env + ); + expect(result).toBeNull(); + }); + + it("returns null for expired session", async () => { + const env = makeEnv(); + // Mock Date.now to issue a session in the past + const realNow = Date.now; + const pastTime = Date.now() - 9 * 3600 * 1000; // 9 hours ago (> 8h SESSION_MAX_AGE) + vi.spyOn(Date, "now").mockReturnValue(pastTime); + const { cookie } = await issueSession(env); + vi.spyOn(Date, "now").mockRestore(); + + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const result = await parseSession( + `__Host-session=${cookieValue}`, + env + ); + expect(result).toBeNull(); + void realNow; // suppress unused warning + }); + + it("accepts a session issued 1 second ago (clock skew)", async () => { + const env = makeEnv(); + const oneSecondAgo = Date.now() - 1000; + vi.spyOn(Date, "now").mockReturnValue(oneSecondAgo); + const { cookie } = await issueSession(env); + vi.spyOn(Date, "now").mockRestore(); + + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const result = await parseSession( + `__Host-session=${cookieValue}`, + env + ); + expect(result).not.toBeNull(); + }); + + it("extracts correct cookie from multi-cookie header", async () => { + const env = makeEnv(); + const { cookie, sessionId } = await issueSession(env); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const multiCookie = `other-cookie=abc; __Host-session=${cookieValue}; another=xyz`; + const parsed = await parseSession(multiCookie, env); + expect(parsed).not.toBeNull(); + expect(parsed!.sid).toBe(sessionId); + }); + + it("signature rotation: signed with old key, verified with new+old", async () => { + const envOld = makeEnv({ SESSION_KEY: KEY_A }); + const { cookie } = await issueSession(envOld); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + + // Now verify with KEY_B as current, KEY_A as prev + const envNew = makeEnv({ + SESSION_KEY: KEY_B, + SESSION_KEY_PREV: KEY_A, + }); + const result = await parseSession( + `__Host-session=${cookieValue}`, + envNew + ); + expect(result).not.toBeNull(); + }); + + it("returns null when old key is not in rotation", async () => { + const envOld = makeEnv({ SESSION_KEY: KEY_A }); + const { cookie } = await issueSession(envOld); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + + // KEY_B only, no KEY_A in rotation + const envNew = makeEnv({ SESSION_KEY: KEY_B }); + const result = await parseSession( + `__Host-session=${cookieValue}`, + envNew + ); + expect(result).toBeNull(); + }); + + it("returns null for malformed cookie value (no dot separator)", async () => { + const result = await parseSession( + "__Host-session=nodothere", + makeEnv() + ); + expect(result).toBeNull(); + }); + + it("returns null for garbage cookie value", async () => { + const result = await parseSession( + "__Host-session=!!!garbage!!!", + makeEnv() + ); + expect(result).toBeNull(); + }); +}); + +describe("clearSession", () => { + it("returns Max-Age=0", () => { + expect(clearSession()).toContain("Max-Age=0"); + }); + + it("returns __Host-session= with empty value", () => { + const result = clearSession(); + expect(result).toMatch(/^__Host-session=;/); + }); + + it("includes required security attributes", () => { + const result = clearSession(); + expect(result).toContain("Path=/"); + expect(result).toContain("Secure"); + expect(result).toContain("HttpOnly"); + expect(result).toContain("SameSite=Strict"); + }); +}); + +describe("ensureSession", () => { + function makeRequest(cookieHeader?: string): Request { + const headers: Record = {}; + if (cookieHeader) headers["Cookie"] = cookieHeader; + return new Request("https://gh.gordoncode.dev/api/proxy/seal", { + headers, + }); + } + + it("issues new session when no cookie present", async () => { + const env = makeEnv(); + const req = makeRequest(); + const result = await ensureSession(req, env); + expect(result.sessionId).toBeTruthy(); + expect(result.setCookie).toBeDefined(); + expect(result.setCookie).toContain("__Host-session="); + }); + + it("reuses existing valid session, no setCookie", async () => { + const env = makeEnv(); + const { cookie, sessionId } = await issueSession(env); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const req = makeRequest(`__Host-session=${cookieValue}`); + const result = await ensureSession(req, env); + expect(result.sessionId).toBe(sessionId); + expect(result.setCookie).toBeUndefined(); + }); + + it("issues new session when existing session is expired", async () => { + const env = makeEnv(); + const pastTime = Date.now() - 9 * 3600 * 1000; + vi.spyOn(Date, "now").mockReturnValue(pastTime); + const { cookie } = await issueSession(env); + vi.spyOn(Date, "now").mockRestore(); + + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + const req = makeRequest(`__Host-session=${cookieValue}`); + const result = await ensureSession(req, env); + expect(result.setCookie).toBeDefined(); + }); + + it("issues new session when cookie signature is invalid", async () => { + const req = makeRequest( + "__Host-session=fakepayload.fakesignature" + ); + const result = await ensureSession(req, makeEnv()); + expect(result.setCookie).toBeDefined(); + }); +}); diff --git a/tests/worker/turnstile.test.ts b/tests/worker/turnstile.test.ts new file mode 100644 index 00000000..079fb048 --- /dev/null +++ b/tests/worker/turnstile.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { verifyTurnstile, extractTurnstileToken } from "../../src/worker/turnstile"; + +const TEST_ENV = { TURNSTILE_SECRET_KEY: "test-turnstile-secret" }; +const TEST_TOKEN = "test-turnstile-token"; +const TEST_IP = "1.2.3.4"; + +// Mock global fetch for each test +const mockFetch = vi.fn(); +const originalFetch = globalThis.fetch; + +// Mock crypto.randomUUID for idempotency key tests +const mockRandomUUID = vi.fn().mockReturnValue("test-uuid-1234-5678-abcd-ef0123456789"); + +beforeEach(() => { + globalThis.fetch = mockFetch; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (crypto as any).randomUUID = mockRandomUUID; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); +}); + +// ── verifyTurnstile ───────────────────────────────────────────────────────── + +describe("verifyTurnstile", () => { + it("returns success: true on successful verification", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: true }); + }); + + it("returns success: false with errorCodes on failed verification", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + "error-codes": ["timeout-or-duplicate"], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ + success: false, + errorCodes: ["timeout-or-duplicate"], + }); + }); + + it("returns network-error when fetch throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network connection refused")); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: false, errorCodes: ["network-error"] }); + }); + + it("returns network-error when response body is not valid JSON", async () => { + mockFetch.mockResolvedValueOnce( + new Response("not-json", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: false, errorCodes: ["network-error"] }); + }); + + it("omits remoteip from form data when ip is null", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await verifyTurnstile(TEST_TOKEN, null, TEST_ENV); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = options.body as FormData; + expect(body.has("remoteip")).toBe(false); + expect(body.get("secret")).toBe(TEST_ENV.TURNSTILE_SECRET_KEY); + expect(body.get("response")).toBe(TEST_TOKEN); + }); + + it("includes remoteip when ip is provided", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = options.body as FormData; + expect(body.get("remoteip")).toBe(TEST_IP); + }); + + it("includes idempotency_key in request body", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = options.body as FormData; + expect(body.has("idempotency_key")).toBe(true); + expect(body.get("idempotency_key")).toBe("test-uuid-1234-5678-abcd-ef0123456789"); + }); + + it("uses redirect: error for SSRF hardening", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify"); + expect(options.redirect).toBe("error"); + expect(options.method).toBe("POST"); + }); + + it("sends to the correct Cloudflare siteverify URL", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify"); + }); + + it("returns empty errorCodes array when response has no error-codes field", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: false, errorCodes: [] }); + }); +}); + +// ── extractTurnstileToken ─────────────────────────────────────────────────── + +describe("extractTurnstileToken", () => { + it("extracts cf-turnstile-response header value", () => { + const request = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + headers: { "cf-turnstile-response": "my-token-value" }, + }); + expect(extractTurnstileToken(request)).toBe("my-token-value"); + }); + + it("returns null when cf-turnstile-response header is absent", () => { + const request = new Request("https://gh.gordoncode.dev/api/proxy/seal"); + expect(extractTurnstileToken(request)).toBeNull(); + }); + + it("returns the raw header value without modification", () => { + const token = "a.b.c.VERY_LONG_TOKEN_VALUE_123456789"; + const request = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + headers: { "cf-turnstile-response": token }, + }); + expect(extractTurnstileToken(request)).toBe(token); + }); +}); From af55386e1bd9c5c7cad515a633806935a735df1a Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 21:37:33 -0400 Subject: [PATCH 04/56] feat(worker): integrate security middleware into fetch handler --- .dev.vars.example | 3 + .env.example | 7 + src/worker/index.ts | 199 ++++++++++++++++- tests/worker/csp-report.test.ts | 4 + tests/worker/oauth.test.ts | 4 + tests/worker/seal.test.ts | 365 ++++++++++++++++++++++++++++++++ wrangler.toml | 9 + 7 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 tests/worker/seal.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index acf7c571..0780bbff 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,6 @@ GITHUB_CLIENT_ID=your_client_id_here GITHUB_CLIENT_SECRET=your_client_secret_here ALLOWED_ORIGIN=http://localhost:5173 +SESSION_KEY=your-base64-encoded-32-byte-key +SEAL_KEY=your-base64-encoded-32-byte-key +TURNSTILE_SECRET_KEY=your-turnstile-secret-from-cf-dashboard diff --git a/.env.example b/.env.example index 9dc07a3a..897c8b40 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,10 @@ GITHUB_TOKEN=your_github_token_here # Port for the WebSocket relay server (MCP ↔ browser dashboard bridge). # Default: 9876 # MCP_WS_PORT=9876 + +# ── Turnstile (Cloudflare) ───────────────────────────────────────────────────── +# Public site key — embedded into client-side bundle at build time by Vite. +# This is public information (visible in the Turnstile widget script). +# Get this from the Cloudflare Turnstile dashboard. +# Note: TURNSTILE_SECRET_KEY is a Worker secret (goes in .dev.vars, not .env). +VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key-from-cf-dashboard diff --git a/src/worker/index.ts b/src/worker/index.ts index 105fff0a..7df017b1 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,10 +1,20 @@ -export interface Env { +import { CryptoEnv, deriveKey, sealToken, SEAL_SALT } from "./crypto"; +import { SessionEnv, ensureSession } from "./session"; +import { TurnstileEnv, verifyTurnstile, extractTurnstileToken } from "./turnstile"; +import { validateProxyRequest } from "./validation"; + +interface RateLimiter { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + +export interface Env extends CryptoEnv, SessionEnv, TurnstileEnv { ASSETS: { fetch: (request: Request) => Promise }; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; ALLOWED_ORIGIN: string; SENTRY_DSN?: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123" SENTRY_SECURITY_TOKEN?: string; // Optional: Sentry security token for Allowed Domains validation + PROXY_RATE_LIMITER: RateLimiter; // Workers Rate Limiting Binding } // Predefined error strings only (SDR-006) @@ -12,7 +22,14 @@ type ErrorCode = | "token_exchange_failed" | "invalid_request" | "method_not_allowed" - | "not_found"; + | "not_found" + | "origin_mismatch" + | "cross_site_request" + | "missing_csrf_header" + | "invalid_content_type" + | "turnstile_failed" + | "rate_limited" + | "seal_failed"; // Structured logging — Cloudflare auto-indexes JSON fields for querying. // NEVER log secrets: codes, tokens, client_secret, cookie values. @@ -41,7 +58,7 @@ function log( function errorResponse( code: ErrorCode, status: number, - corsHeaders: Record + corsHeaders: Record = {} ): Response { return new Response(JSON.stringify({ error: code }), { status, @@ -108,6 +125,138 @@ function getCorsHeaders( return {}; } +// ── Proxy CORS headers ───────────────────────────────────────────────────── +// SC-7: Must check requestOrigin === allowedOrigin before reflecting. +// Returns empty object if no match — never reflects untrusted origins. +function getProxyCorsHeaders( + requestOrigin: string | null, + allowedOrigin: string +): Record { + if (requestOrigin !== allowedOrigin) return {}; + return { + "Access-Control-Allow-Origin": allowedOrigin, + "Access-Control-Allow-Methods": "POST, GET", + "Access-Control-Allow-Headers": "Content-Type, X-Requested-With, cf-turnstile-response", + "Vary": "Origin", + }; +} + +// ── Proxy route patterns ───────────────────────────────────────────────────── +function isProxyPath(pathname: string): boolean { + return ( + pathname.startsWith("/api/proxy/") || + pathname.startsWith("/api/jira/") || + pathname.startsWith("/api/gitlab/") + ); +} + +// ── Validation gate for proxy routes ───────────────────────────────────────── +// Returns a Response if rejected, null if validation passes. +function validateAndGuardProxyRoute(request: Request, env: Env): Response | null { + const url = new URL(request.url); + const pathname = url.pathname; + + if (!isProxyPath(pathname)) return null; + + const origin = request.headers.get("Origin"); + + // Handle OPTIONS preflight for proxy routes explicitly. + // Legitimate SPA requests are same-origin and don't trigger preflight, + // so this handler exists only to explicitly reject cross-origin preflights. + if (request.method === "OPTIONS") { + const corsHeaders = getProxyCorsHeaders(origin, env.ALLOWED_ORIGIN); + if (Object.keys(corsHeaders).length === 0) { + return new Response(null, { status: 403, headers: SECURITY_HEADERS }); + } + return new Response(null, { + status: 204, + headers: { ...corsHeaders, "Access-Control-Max-Age": "86400", ...SECURITY_HEADERS }, + }); + } + + const result = validateProxyRequest(request, env.ALLOWED_ORIGIN); + if (!result.ok) { + log("warn", "proxy_validation_failed", { code: result.code, pathname }, request); + return errorResponse(result.code as ErrorCode, result.status); + } + + return null; +} + +// ── Sealed-token endpoint ──────────────────────────────────────────────────── +async function handleProxySeal(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return errorResponse("method_not_allowed", 405); + } + + // Session + rate limiting (done by caller, sessionId passed in) + // Extract Turnstile token and verify (Step 6) + const turnstileToken = extractTurnstileToken(request); + if (!turnstileToken) { + log("warn", "seal_turnstile_missing", {}, request); + return errorResponse("turnstile_failed", 403); + } + + const ip = request.headers.get("CF-Connecting-IP"); + const turnstileResult = await verifyTurnstile(turnstileToken, ip, env); + if (!turnstileResult.success) { + log("warn", "seal_turnstile_failed", { error_codes: turnstileResult.errorCodes }, request); + return errorResponse("turnstile_failed", 403); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse("invalid_request", 400); + } + + if (typeof body !== "object" || body === null) { + return errorResponse("invalid_request", 400); + } + + const token = (body as Record)["token"]; + const purpose = (body as Record)["purpose"]; + + if (typeof token !== "string") { + return errorResponse("invalid_request", 400); + } + if (token.length > 2048) { + return errorResponse("invalid_request", 400); + } + // SC-8: purpose field required for token audience binding + if (typeof purpose !== "string" || purpose.length === 0) { + return errorResponse("invalid_request", 400); + } + + let sealed: string; + try { + // SC-8: derive key with purpose-scoped info string + const key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt"); + sealed = await sealToken(token, key); + } catch (err) { + // SC-9: log error server-side but DO NOT include crypto error in response + log("error", "seal_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + return errorResponse("seal_failed", 500); + } + + // SC-11: log seal operations + log("info", "token_sealed", { + purpose, + token_length: token.length, + }, request); + + return new Response(JSON.stringify({ sealed }), { + status: 200, + headers: { + "Content-Type": "application/json", + ...SECURITY_HEADERS, + }, + }); +} + // ── 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). @@ -220,6 +369,7 @@ async function handleSentryTunnel( method: "POST", headers: sentryHeaders, body, + redirect: "error", }); log("info", "sentry_tunnel_forwarded", { @@ -329,6 +479,7 @@ async function handleCspReport(request: Request, env: Env): Promise { ...(env.SENTRY_SECURITY_TOKEN ? { "X-Sentry-Token": env.SENTRY_SECURITY_TOKEN } : {}), }, body: JSON.stringify(payload), + redirect: "error", }).catch(() => null) ) ); @@ -432,6 +583,7 @@ async function handleTokenExchange( client_secret: env.GITHUB_CLIENT_SECRET, code, }), + redirect: "error", } ); githubStatus = githubResp.status; @@ -534,6 +686,47 @@ export default { }); } + // ── Proxy routes: validation, session, and rate limiting ───────────────── + // Applies to /api/proxy/*, /api/jira/*, /api/gitlab/* + // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. + const guardResponse = validateAndGuardProxyRoute(request, env); + if (guardResponse !== null) return guardResponse; + + if (isProxyPath(url.pathname)) { + // Step 3: Session middleware — ensureSession never throws (SDR-003) + const { sessionId, setCookie } = await ensureSession(request, env); + + // Step 4: Rate limiting using session ID as key + const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); + if (!success) { + log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); + const rateLimitResponse = errorResponse("rate_limited", 429); + const headers = new Headers(rateLimitResponse.headers); + headers.set("Retry-After", "60"); + if (setCookie) headers.set("Set-Cookie", setCookie); + return new Response(rateLimitResponse.body, { + status: 429, + headers, + }); + } + + // Step 5: Sealed-token endpoint + if (url.pathname === "/api/proxy/seal") { + const sealResponse = await handleProxySeal(request, env); + if (setCookie) { + const headers = new Headers(sealResponse.headers); + headers.set("Set-Cookie", setCookie); + return new Response(sealResponse.body, { + status: sealResponse.status, + headers, + }); + } + return sealResponse; + } + + // Other proxy routes not yet implemented — fall through to 404 + } + if (url.pathname.startsWith("/api/")) { log("warn", "api_not_found", { method: request.method, diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index c3cb07fc..5847df87 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -8,6 +8,10 @@ function makeEnv(overrides: Partial = {}): Env { GITHUB_CLIENT_SECRET: "test_client_secret", ALLOWED_ORIGIN: "https://gh.gordoncode.dev", SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", + SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==", + SEAL_KEY: "dGVzdC1zZWFsLWtleQ==", + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, ...overrides, }; } diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index e6975bd7..db03396e 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -10,6 +10,10 @@ function makeEnv(overrides: Partial = {}): Env { GITHUB_CLIENT_SECRET: "test_client_secret", ALLOWED_ORIGIN, SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", + SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==", + SEAL_KEY: "dGVzdC1zZWFsLWtleQ==", + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, ...overrides, }; } diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts new file mode 100644 index 00000000..14702027 --- /dev/null +++ b/tests/worker/seal.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; + +const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; + +// Valid base64url-encoded 32-byte keys for testing +// "test-session-key-32bytes-padding!" base64-encoded +const TEST_SESSION_KEY = "dGVzdC1zZXNzaW9uLWtleQ=="; +// "test-seal-key-32bytes-padding!!!!" base64-encoded +const TEST_SEAL_KEY = "dGVzdC1zZWFsLWtleQ=="; + +function makeEnv(overrides: Partial = {}): Env { + return { + ASSETS: { fetch: async () => new Response("asset") }, + GITHUB_CLIENT_ID: "test_client_id", + GITHUB_CLIENT_SECRET: "test_client_secret", + ALLOWED_ORIGIN, + SESSION_KEY: TEST_SESSION_KEY, + SEAL_KEY: TEST_SEAL_KEY, + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, + ...overrides, + }; +} + +function makeSealRequest(options: { + body?: unknown; + origin?: string; + addXRequestedWith?: boolean; + addContentType?: boolean; + turnstileToken?: string; + method?: string; +} = {}): Request { + const { + body = { token: "ghp_test_token_123", purpose: "jira-api" }, + origin = ALLOWED_ORIGIN, + addXRequestedWith = true, + addContentType = true, + turnstileToken = "valid-turnstile-token", + method = "POST", + } = options; + + const headers: Record = {}; + if (origin) headers["Origin"] = origin; + if (addXRequestedWith) headers["X-Requested-With"] = "fetch"; + if (addContentType) headers["Content-Type"] = "application/json"; + if (turnstileToken) headers["cf-turnstile-response"] = turnstileToken; + // Sec-Fetch-Site is omitted to simulate legacy browser (passes validation) + + return new Request(`https://gh.gordoncode.dev/api/proxy/seal`, { + method, + headers, + body: method !== "GET" && body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +describe("Worker /api/proxy/seal endpoint", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // ── Valid request ───────────────────────────────────────────────────────── + + it("valid request with all headers + mocked Turnstile returns sealed token", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + expect((json["sealed"] as string).length).toBeGreaterThan(0); + // Sealed token should be base64url (no +, /, = chars) + expect(json["sealed"]).not.toMatch(/[+/=]/); + }); + + // ── Validation failures ─────────────────────────────────────────────────── + + it("request missing X-Requested-With returns 403 with missing_csrf_header", async () => { + const req = makeSealRequest({ addXRequestedWith: false }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("missing_csrf_header"); + }); + + it("request with wrong Origin returns 403 with origin_mismatch", async () => { + const req = makeSealRequest({ origin: "https://evil.example.com" }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("origin_mismatch"); + }); + + it("request with Sec-Fetch-Site: cross-site returns 403 with cross_site_request", async () => { + const headers: Record = { + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-token", + "Sec-Fetch-Site": "cross-site", + }; + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers, + body: JSON.stringify({ token: "test", purpose: "jira-api" }), + }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("cross_site_request"); + }); + + // ── Turnstile failures ──────────────────────────────────────────────────── + + it("request with failed Turnstile returns 403 with turnstile_failed", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ success: false, "error-codes": ["timeout-or-duplicate"] }), + { status: 200 } + ) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("turnstile_failed"); + }); + + it("request with missing Turnstile token returns 403 with turnstile_failed", async () => { + const req = makeSealRequest({ turnstileToken: "" }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("turnstile_failed"); + }); + + // ── Rate limiting ───────────────────────────────────────────────────────── + + it("request exceeding rate limit returns 429 with rate_limited and Retry-After header", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const rateLimiter = { limit: vi.fn().mockResolvedValue({ success: false }) }; + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv({ PROXY_RATE_LIMITER: rateLimiter })); + + expect(res.status).toBe(429); + const json = await res.json() as Record; + expect(json["error"]).toBe("rate_limited"); + expect(res.headers.get("Retry-After")).toBe("60"); + }); + + // ── Input validation ────────────────────────────────────────────────────── + + it("request with token exceeding 2048 chars returns 400 with invalid_request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const longToken = "a".repeat(2049); + const req = makeSealRequest({ body: { token: longToken, purpose: "jira-api" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("request with token exactly 2048 chars is accepted", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const maxToken = "a".repeat(2048); + const req = makeSealRequest({ body: { token: maxToken, purpose: "jira-api" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + }); + + it("request with missing purpose returns 400 with invalid_request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "ghp_test" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("request with empty purpose string returns 400 with invalid_request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "ghp_test", purpose: "" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("request with missing token returns 400 with invalid_request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { purpose: "jira-api" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + // ── OPTIONS preflight ───────────────────────────────────────────────────── + + it("OPTIONS preflight with valid origin returns 204 with correct CORS headers", async () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "OPTIONS", + headers: { + "Origin": ALLOWED_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type, X-Requested-With, cf-turnstile-response", + }, + }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + const allowHeaders = res.headers.get("Access-Control-Allow-Headers") ?? ""; + expect(allowHeaders).toContain("Content-Type"); + expect(allowHeaders).toContain("X-Requested-With"); + expect(allowHeaders).toContain("cf-turnstile-response"); + const allowMethods = res.headers.get("Access-Control-Allow-Methods") ?? ""; + expect(allowMethods).toContain("POST"); + }); + + it("OPTIONS preflight with wrong origin returns 403", async () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "OPTIONS", + headers: { + "Origin": "https://evil.example.com", + "Access-Control-Request-Method": "POST", + }, + }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + + // ── Session cookie issuance ─────────────────────────────────────────────── + + it("first request issues a session cookie in Set-Cookie", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + const setCookie = res.headers.get("Set-Cookie"); + expect(setCookie).not.toBeNull(); + expect(setCookie).toContain("__Host-session="); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Strict"); + }); + + // ── Crypto failure (sealToken throws) ──────────────────────────────────── + + it("when sealToken fails due to invalid key, returns 500 with seal_failed", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + // Use an invalid (non-base64url) key to force a crypto failure in deriveKey + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv({ SEAL_KEY: "!!not-valid-base64!!" })); + + expect(res.status).toBe(500); + const json = await res.json() as Record; + expect(json["error"]).toBe("seal_failed"); + // Must not include crypto error details in response (SC-9) + expect(JSON.stringify(json)).not.toContain("DOMException"); + expect(JSON.stringify(json)).not.toContain("DataError"); + }); + + // ── Security headers ────────────────────────────────────────────────────── + + it("responses include security headers", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest(); + 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"); + expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + }); + + // ── SC-11: seal operation logging ───────────────────────────────────────── + + it("successful seal logs token_sealed event with purpose and token_length", async () => { + const consoleSpy = { + info: vi.spyOn(console, "info"), + warn: vi.spyOn(console, "warn"), + error: vi.spyOn(console, "error"), + }; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "test-purpose" } }); + await worker.fetch(req, makeEnv()); + + const allLogs: Array> = []; + for (const [, spy] of Object.entries(consoleSpy)) { + for (const call of spy.mock.calls) { + try { + allLogs.push(JSON.parse(call[0] as string) as Record); + } catch { + // ignore non-JSON + } + } + } + const sealLog = allLogs.find((l) => l["event"] === "token_sealed"); + expect(sealLog).toBeDefined(); + expect(sealLog!["purpose"]).toBe("test-purpose"); + expect(sealLog!["token_length"]).toBe(10); // "ghp_abc123".length + // Must NOT log the actual token value + const allLogText = allLogs.map((l) => JSON.stringify(l)).join("\n"); + expect(allLogText).not.toContain("ghp_abc123"); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index e2a74cf2..b3b0211b 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,7 @@ name = "github-tracker" main = "src/worker/index.ts" compatibility_date = "2026-03-01" +compatibility_flags = ["global_fetch_strictly_public"] workers_dev = false [assets] @@ -17,6 +18,14 @@ custom_domain = true [vars] SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240" +[[ratelimits]] +name = "PROXY_RATE_LIMITER" +namespace_id = "1001" + +[ratelimits.simple] +limit = 60 +period = 60 + [observability] enabled = true head_sampling_rate = 1 From 94de31000e197092f0ee000814dec5e40362be7d Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 21:42:22 -0400 Subject: [PATCH 05/56] feat(auth): adds Turnstile widget, seal helper, proxy utilities --- src/app/lib/proxy.ts | 130 ++++++++++++ src/types/turnstile.d.ts | 23 +++ tests/app/lib/proxy.test.ts | 393 ++++++++++++++++++++++++++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 src/app/lib/proxy.ts create mode 100644 src/types/turnstile.d.ts create mode 100644 tests/app/lib/proxy.test.ts diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts new file mode 100644 index 00000000..fee25170 --- /dev/null +++ b/src/app/lib/proxy.ts @@ -0,0 +1,130 @@ +// SPA-side proxy utilities: Turnstile script loader, token acquisition, +// sealed-token helper, and proxyFetch wrapper. + +const TURNSTILE_SCRIPT_URL = + "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + +let turnstilePromise: Promise | null = null; + +function loadTurnstileScript(): Promise { + if (turnstilePromise !== null) { + return turnstilePromise; + } + turnstilePromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = TURNSTILE_SCRIPT_URL; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => { + script.remove(); + turnstilePromise = null; + reject(new Error("Failed to load Turnstile script")); + }; + document.head.appendChild(script); + }); + return turnstilePromise; +} + +export async function acquireTurnstileToken(siteKey: string): Promise { + if (!siteKey) { + throw new Error("VITE_TURNSTILE_SITE_KEY not configured"); + } + + await loadTurnstileScript(); + + return new Promise((resolve, reject) => { + let settled = false; + + const container = document.createElement("div"); + container.style.cssText = + "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999; min-width: 300px; min-height: 65px;"; + document.body.appendChild(container); + + const cleanup = (widgetId: string) => { + window.turnstile.remove(widgetId); + container.remove(); + }; + + const widgetId = window.turnstile.render(container, { + sitekey: siteKey, + size: "invisible", + execution: "execute", + callback: (token: string) => { + if (settled) return; + settled = true; + cleanup(widgetId); + resolve(token); + }, + "error-callback": (errorCode: string) => { + if (settled) return; + settled = true; + cleanup(widgetId); + reject(new Error(`Turnstile error: ${errorCode}`)); + }, + "expired-callback": () => { + if (settled) return; + settled = true; + cleanup(widgetId); + reject(new Error("Turnstile token expired before submission")); + }, + }); + + window.turnstile.execute(widgetId); + + setTimeout(() => { + if (settled) return; + settled = true; + cleanup(widgetId); + reject(new Error("Turnstile challenge timed out after 30 seconds")); + }, 30_000); + }); +} + +export async function proxyFetch( + path: string, + options?: RequestInit, +): Promise { + const defaultHeaders: Record = { + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }; + + const callerHeaders = + options?.headers instanceof Headers + ? Object.fromEntries(options.headers.entries()) + : (options?.headers as Record | undefined) ?? {}; + + const mergedHeaders = { ...defaultHeaders, ...callerHeaders }; + + return fetch(path, { + ...options, + headers: mergedHeaders, + }); +} + +export async function sealApiToken(token: string): Promise { + const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; + const turnstileToken = await acquireTurnstileToken(siteKey ?? ""); + + const res = await proxyFetch("/api/proxy/seal", { + method: "POST", + headers: { + "cf-turnstile-response": turnstileToken, + }, + body: JSON.stringify({ token }), + }); + + if (!res.ok) { + let message = "unknown_error"; + try { + const body = (await res.json()) as { code?: string; error?: string }; + message = body.code ?? body.error ?? message; + } catch { + // ignore parse errors — keep default message + } + throw { status: res.status, message }; + } + + const data = (await res.json()) as { sealed: string }; + return data.sealed; +} diff --git a/src/types/turnstile.d.ts b/src/types/turnstile.d.ts new file mode 100644 index 00000000..a7472f37 --- /dev/null +++ b/src/types/turnstile.d.ts @@ -0,0 +1,23 @@ +// Cloudflare Turnstile client-side API type declarations. +// Turnstile assigns `window.turnstile` synchronously when its script executes. + +interface TurnstileRenderOptions { + sitekey: string; + size?: "normal" | "compact" | "invisible" | "flexible"; + execution?: "render" | "execute"; + callback?: (token: string) => void; + "error-callback"?: (errorCode: string) => void; + "expired-callback"?: () => void; + "timeout-callback"?: () => void; +} + +interface Turnstile { + render(container: HTMLElement | string, options: TurnstileRenderOptions): string; + execute(widgetId: string): void; + remove(widgetId: string): void; + reset(widgetId: string): void; +} + +interface Window { + turnstile: Turnstile; +} diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts new file mode 100644 index 00000000..63ffd56b --- /dev/null +++ b/tests/app/lib/proxy.test.ts @@ -0,0 +1,393 @@ +// Tests for SPA-side proxy utilities (src/app/lib/proxy.ts). +// Turnstile widget rendering requires a real browser — mock window.turnstile. +// Full widget lifecycle is covered by E2E tests. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── Module reset helpers ────────────────────────────────────────────────────── + +async function loadModule() { + vi.resetModules(); + return import("../../../src/app/lib/proxy"); +} + +// ── Mock Turnstile factory ──────────────────────────────────────────────────── + +interface MockTurnstile { + render: ReturnType; + execute: ReturnType; + remove: ReturnType; + reset: ReturnType; + /** Trigger the success callback for the most-recently rendered widget. */ + _resolveToken(token: string): void; + /** Trigger the error callback for the most-recently rendered widget. */ + _rejectWithError(code: string): void; +} + +function makeMockTurnstile(): MockTurnstile { + let _successCb: ((token: string) => void) | undefined; + let _errorCb: ((code: string) => void) | undefined; + + const mock: MockTurnstile = { + render: vi.fn((_container: HTMLElement, options: { callback?: (token: string) => void; "error-callback"?: (code: string) => void }) => { + _successCb = options.callback; + _errorCb = options["error-callback"]; + return "widget-id-1"; + }), + execute: vi.fn(), + remove: vi.fn(), + reset: vi.fn(), + _resolveToken(token: string) { + _successCb?.(token); + }, + _rejectWithError(code: string) { + _errorCb?.(code); + }, + }; + + return mock; +} + +// ── proxyFetch tests ────────────────────────────────────────────────────────── + +describe("proxyFetch", () => { + let mod: typeof import("../../../src/app/lib/proxy"); + + beforeEach(async () => { + mod = await loadModule(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("sets X-Requested-With: fetch automatically", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Requested-With"]).toBe("fetch"); + }); + + it("sets Content-Type: application/json automatically", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("caller-provided headers override defaults", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal", { + headers: { "Content-Type": "text/plain" }, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + // Caller value takes precedence + expect(headers["Content-Type"]).toBe("text/plain"); + // Default still set + expect(headers["X-Requested-With"]).toBe("fetch"); + }); + + it("merges extra caller headers without dropping defaults", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal", { + headers: { "cf-turnstile-response": "tok123" }, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Requested-With"]).toBe("fetch"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["cf-turnstile-response"]).toBe("tok123"); + }); + + it("passes the path to fetch unchanged", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal"); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/proxy/seal"); + }); +}); + +// ── acquireTurnstileToken tests ─────────────────────────────────────────────── + +describe("acquireTurnstileToken", () => { + let mod: typeof import("../../../src/app/lib/proxy"); + + beforeEach(async () => { + mod = await loadModule(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("throws immediately when siteKey is empty", async () => { + await expect(mod.acquireTurnstileToken("")).rejects.toThrow( + "VITE_TURNSTILE_SITE_KEY not configured", + ); + }); + + it("throws immediately when siteKey is undefined-like empty", async () => { + await expect( + mod.acquireTurnstileToken("" as string), + ).rejects.toThrow("VITE_TURNSTILE_SITE_KEY not configured"); + }); + + it("resolves with token when Turnstile callback fires", async () => { + const mockTurnstile = makeMockTurnstile(); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + // Mock script loading: stub createElement so the script tag triggers onload + const realCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = realCreateElement(tag); + if (tag === "script") { + // Trigger onload synchronously after assignment + const originalSet = Object.getOwnPropertyDescriptor(el, "onload")?.set; + Object.defineProperty(el, "onload", { + set(fn: () => void) { + if (originalSet) originalSet.call(this, fn); + // Schedule onload after current microtask + Promise.resolve().then(() => fn?.()); + }, + get() { return null; }, + configurable: true, + }); + // Prevent actual DOM insertion by stubbing appendChild on head + } + return el; + }); + + const realHeadAppend = document.head.appendChild.bind(document.head); + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + // Trigger onload immediately + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return realHeadAppend(node); + }); + + const tokenPromise = mod.acquireTurnstileToken("test-site-key"); + + // Allow the loadTurnstileScript + render to complete + await Promise.resolve(); + await Promise.resolve(); + + // Fire the success callback + mockTurnstile._resolveToken("test-token-abc"); + + const token = await tokenPromise; + expect(token).toBe("test-token-abc"); + }); + + it("rejects when Turnstile fires error-callback", async () => { + const mockTurnstile = makeMockTurnstile(); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + const tokenPromise = mod.acquireTurnstileToken("test-site-key"); + + await Promise.resolve(); + await Promise.resolve(); + + mockTurnstile._rejectWithError("invalid-input-response"); + + await expect(tokenPromise).rejects.toThrow("Turnstile error: invalid-input-response"); + }); +}); + +// ── sealApiToken tests ──────────────────────────────────────────────────────── + +describe("sealApiToken", () => { + let mod: typeof import("../../../src/app/lib/proxy"); + + beforeEach(async () => { + vi.resetModules(); + // Set VITE_TURNSTILE_SITE_KEY env + vi.stubEnv("VITE_TURNSTILE_SITE_KEY", "test-site-key"); + mod = await loadModule(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + function setupMockedTurnstile(token: string) { + const mockTurnstile = makeMockTurnstile(); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + // Immediately resolve turnstile token after render + execute + mockTurnstile.execute.mockImplementation(() => { + mockTurnstile._resolveToken(token); + }); + + return mockTurnstile; + } + + it("resolves with sealed string on success (200 response)", async () => { + setupMockedTurnstile("turnstile-tok-ok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sealed: "enc:abc123" }), { status: 200 }), + ); + vi.stubGlobal("fetch", mockFetch); + + const result = await mod.sealApiToken("my-raw-api-token"); + expect(result).toBe("enc:abc123"); + }); + + it("throws { status, message } on 403 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "turnstile_failed" }), { status: 403 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + status: 403, + message: "turnstile_failed", + }); + }); + + it("throws { status, message } on 429 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "rate_limited" }), { status: 429 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + status: 429, + message: "rate_limited", + }); + }); + + it("throws { status, message } on 500 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: "seal_failed" }), { status: 500 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + status: 500, + message: "seal_failed", + }); + }); + + it("rejects when fetch throws a network error", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", mockFetch); + + await expect(mod.sealApiToken("my-token")).rejects.toThrow("Failed to fetch"); + }); + + it("includes cf-turnstile-response header in POST body", async () => { + setupMockedTurnstile("expected-turnstile-token"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sealed: "enc:xyz" }), { status: 200 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await mod.sealApiToken("raw-token"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["cf-turnstile-response"]).toBe("expected-turnstile-token"); + }); + + it("sends POST to /api/proxy/seal", async () => { + setupMockedTurnstile("tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sealed: "enc:abc" }), { status: 200 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await mod.sealApiToken("raw-token"); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/proxy/seal"); + expect(init.method).toBe("POST"); + }); + + it("sends token in the request body as JSON", async () => { + setupMockedTurnstile("tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sealed: "enc:abc" }), { status: 200 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await mod.sealApiToken("my-raw-token"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string) as { token: string }; + expect(body.token).toBe("my-raw-token"); + }); + + it("throws immediately when VITE_TURNSTILE_SITE_KEY is not set", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("VITE_TURNSTILE_SITE_KEY", ""); + + const freshMod = await loadModule(); + await expect(freshMod.sealApiToken("raw-token")).rejects.toThrow( + "VITE_TURNSTILE_SITE_KEY not configured", + ); + }); +}); From 908a19545fb575eabd22bd217db12f31160f3583 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 22:00:37 -0400 Subject: [PATCH 06/56] fix(worker): address security and QA review findings - sealApiToken: add purpose parameter, include in POST body (CRIT-001, 6/7 reviewers) - ensureSession: wrap issueSession in try/catch, fallback to random sessionId on error (SEC-002, STRUCT-005) - handleProxySeal: add VALID_PURPOSES allowlist + 64-char max-length for purpose field (SEC-003, QA-002) - validateAndGuardProxyRoute: include CORS headers on validation error responses (SEC-004) - session.ts: cache derived HMAC keys at module level to avoid repeated HKDF derivation (PERF-001/002) - turnstile.ts: add 5s AbortController timeout to siteverify fetch (PERF-003) - proxy.test.ts: update sealApiToken calls with purpose, assert body.purpose field, add error field test - seal.test.ts: update purpose values to match VALID_PURPOSES allowlist - crypto.test.ts: add cross-purpose isolation test (F-003) --- src/app/lib/proxy.ts | 4 +-- src/worker/index.ts | 8 +++++- src/worker/session.ts | 53 ++++++++++++++++++++++++------------- src/worker/turnstile.ts | 10 ++++++- tests/app/lib/proxy.test.ts | 37 ++++++++++++++++++-------- tests/worker/crypto.test.ts | 9 +++++++ tests/worker/seal.test.ts | 14 +++++----- 7 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index fee25170..09b7d9e4 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -102,7 +102,7 @@ export async function proxyFetch( }); } -export async function sealApiToken(token: string): Promise { +export async function sealApiToken(token: string, purpose: string): Promise { const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; const turnstileToken = await acquireTurnstileToken(siteKey ?? ""); @@ -111,7 +111,7 @@ export async function sealApiToken(token: string): Promise { headers: { "cf-turnstile-response": turnstileToken, }, - body: JSON.stringify({ token }), + body: JSON.stringify({ token, purpose }), }); if (!res.ok) { diff --git a/src/worker/index.ts b/src/worker/index.ts index 7df017b1..e50a207d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -177,13 +177,16 @@ function validateAndGuardProxyRoute(request: Request, env: Env): Response | null const result = validateProxyRequest(request, env.ALLOWED_ORIGIN); if (!result.ok) { log("warn", "proxy_validation_failed", { code: result.code, pathname }, request); - return errorResponse(result.code as ErrorCode, result.status); + const corsHeaders = getProxyCorsHeaders(origin, env.ALLOWED_ORIGIN); + return errorResponse(result.code as ErrorCode, result.status, corsHeaders); } return null; } // ── Sealed-token endpoint ──────────────────────────────────────────────────── +const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token", "gitlab-pat"]); + async function handleProxySeal(request: Request, env: Env): Promise { if (request.method !== "POST") { return errorResponse("method_not_allowed", 405); @@ -228,6 +231,9 @@ async function handleProxySeal(request: Request, env: Env): Promise { if (typeof purpose !== "string" || purpose.length === 0) { return errorResponse("invalid_request", 400); } + if (purpose.length > 64 || !VALID_PURPOSES.has(purpose)) { + return errorResponse("invalid_request", 400); + } let sealed: string; try { diff --git a/src/worker/session.ts b/src/worker/session.ts index b316bf5c..8c4e211a 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -12,7 +12,7 @@ import { deriveKey, signSession, - verifySessionWithRotation, + verifySession, } from "./crypto"; export interface SessionEnv { @@ -31,6 +31,25 @@ const SESSION_HMAC_SALT = "github-tracker-session-v1"; const SESSION_HMAC_INFO = "session-hmac"; const SESSION_MAX_AGE = 28800; // 8 hours in seconds +// Module-level cache for derived session HMAC keys. +// SESSION_KEY is a deployment constant — safe to cache per-isolate (follows _dsnCache pattern). +let _sessionKeyCache: { raw: string; key: CryptoKey } | undefined; +let _sessionKeyPrevCache: { raw: string; key: CryptoKey } | undefined; + +async function getSessionHmacKey(raw: string): Promise { + if (_sessionKeyCache?.raw === raw) return _sessionKeyCache.key; + const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); + _sessionKeyCache = { raw, key }; + return key; +} + +async function getSessionHmacPrevKey(raw: string): Promise { + if (_sessionKeyPrevCache?.raw === raw) return _sessionKeyPrevCache.key; + const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); + _sessionKeyPrevCache = { raw, key }; + return key; +} + /** * Issues a new signed session cookie. * Returns the Set-Cookie header value and the sessionId for rate-limiting. @@ -46,12 +65,7 @@ export async function issueSession( }; const json = JSON.stringify(payload); - const hmacKey = await deriveKey( - env.SESSION_KEY, - SESSION_HMAC_SALT, - SESSION_HMAC_INFO, - "sign" - ); + const hmacKey = await getSessionHmacKey(env.SESSION_KEY); const signature = await signSession(json, hmacKey); // base64url(JSON(payload)).base64url(HMAC-SHA256(JSON(payload))) @@ -98,15 +112,13 @@ export async function parseSession( const json = atob(paddedPayload + "=".repeat(padding)); const payload = JSON.parse(json) as SessionPayload; - // Verify HMAC signature (rotation-aware) - const valid = await verifySessionWithRotation( - json, - signature, - env.SESSION_KEY, - env.SESSION_KEY_PREV, - SESSION_HMAC_SALT, - SESSION_HMAC_INFO - ); + // Verify HMAC signature (rotation-aware, using cached derived keys) + const currentKey = await getSessionHmacKey(env.SESSION_KEY); + let valid = await verifySession(json, signature, currentKey); + if (!valid && env.SESSION_KEY_PREV !== undefined) { + const prevKey = await getSessionHmacPrevKey(env.SESSION_KEY_PREV); + valid = await verifySession(json, signature, prevKey); + } if (!valid) return null; // Check expiry @@ -141,8 +153,13 @@ export async function ensureSession( return { sessionId: existing.sid }; } - const { cookie, sessionId } = await issueSession(env); - return { sessionId, setCookie: cookie }; + try { + const { cookie, sessionId } = await issueSession(env); + return { sessionId, setCookie: cookie }; + } catch (error) { + console.error("session_issue_failed", error); + return { sessionId: crypto.randomUUID() }; + } } export { SESSION_HMAC_SALT, SESSION_HMAC_INFO }; diff --git a/src/worker/turnstile.ts b/src/worker/turnstile.ts index aa29abfe..a6f51d4f 100644 --- a/src/worker/turnstile.ts +++ b/src/worker/turnstile.ts @@ -30,14 +30,22 @@ export async function verifyTurnstile( body.append("idempotency_key", crypto.randomUUID()); let resp: Response; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); try { resp = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body, redirect: "error", + signal: controller.signal, }); - } catch { + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return { success: false, errorCodes: ["timeout"] }; + } return { success: false, errorCodes: ["network-error"] }; + } finally { + clearTimeout(timeoutId); } let data: TurnstileResponse; diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 63ffd56b..ad76f40e 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -281,7 +281,7 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - const result = await mod.sealApiToken("my-raw-api-token"); + const result = await mod.sealApiToken("my-raw-api-token", "jira-api-token"); expect(result).toBe("enc:abc123"); }); @@ -293,7 +293,21 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ + status: 403, + message: "turnstile_failed", + }); + }); + + it("throws { status, message } on 403 response using error field", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "turnstile_failed" }), { status: 403 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ status: 403, message: "turnstile_failed", }); @@ -307,7 +321,7 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ status: 429, message: "rate_limited", }); @@ -321,7 +335,7 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token")).rejects.toMatchObject({ + await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ status: 500, message: "seal_failed", }); @@ -333,7 +347,7 @@ describe("sealApiToken", () => { const mockFetch = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token")).rejects.toThrow("Failed to fetch"); + await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toThrow("Failed to fetch"); }); it("includes cf-turnstile-response header in POST body", async () => { @@ -344,7 +358,7 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await mod.sealApiToken("raw-token"); + await mod.sealApiToken("raw-token", "jira-api-token"); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; const headers = init.headers as Record; @@ -359,14 +373,14 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await mod.sealApiToken("raw-token"); + await mod.sealApiToken("raw-token", "jira-api-token"); const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect(url).toBe("/api/proxy/seal"); expect(init.method).toBe("POST"); }); - it("sends token in the request body as JSON", async () => { + it("sends token and purpose in the request body as JSON", async () => { setupMockedTurnstile("tok"); const mockFetch = vi.fn().mockResolvedValue( @@ -374,11 +388,12 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await mod.sealApiToken("my-raw-token"); + await mod.sealApiToken("my-raw-token", "jira-api-token"); const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(init.body as string) as { token: string }; + const body = JSON.parse(init.body as string) as { token: string; purpose: string }; expect(body.token).toBe("my-raw-token"); + expect(body.purpose).toBe("jira-api-token"); }); it("throws immediately when VITE_TURNSTILE_SITE_KEY is not set", async () => { @@ -386,7 +401,7 @@ describe("sealApiToken", () => { vi.stubEnv("VITE_TURNSTILE_SITE_KEY", ""); const freshMod = await loadModule(); - await expect(freshMod.sealApiToken("raw-token")).rejects.toThrow( + await expect(freshMod.sealApiToken("raw-token", "jira-api-token")).rejects.toThrow( "VITE_TURNSTILE_SITE_KEY not configured", ); }); diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index 6726552e..036964de 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -155,6 +155,15 @@ describe("sealToken / unsealToken", () => { }); }); +describe("sealToken cross-purpose isolation", () => { + it("cannot unseal a token sealed with a different purpose (F-003)", async () => { + const sealKey = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key:jira-api-token", "encrypt"); + const unsealKey = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key:gitlab-pat", "encrypt"); + const sealed = await sealToken("secret-token", sealKey); + expect(await unsealToken(sealed, unsealKey)).toBeNull(); + }); +}); + describe("unsealTokenWithRotation", () => { it("unseals with current key", async () => { const keyA = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 14702027..042897a6 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -32,7 +32,7 @@ function makeSealRequest(options: { method?: string; } = {}): Request { const { - body = { token: "ghp_test_token_123", purpose: "jira-api" }, + body = { token: "ghp_test_token_123", purpose: "jira-api-token" }, origin = ALLOWED_ORIGIN, addXRequestedWith = true, addContentType = true, @@ -118,7 +118,7 @@ describe("Worker /api/proxy/seal endpoint", () => { const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { method: "POST", headers, - body: JSON.stringify({ token: "test", purpose: "jira-api" }), + body: JSON.stringify({ token: "test", purpose: "jira-api-token" }), }); const res = await worker.fetch(req, makeEnv()); @@ -179,7 +179,7 @@ describe("Worker /api/proxy/seal endpoint", () => { ); const longToken = "a".repeat(2049); - const req = makeSealRequest({ body: { token: longToken, purpose: "jira-api" } }); + const req = makeSealRequest({ body: { token: longToken, purpose: "jira-api-token" } }); const res = await worker.fetch(req, makeEnv()); expect(res.status).toBe(400); @@ -193,7 +193,7 @@ describe("Worker /api/proxy/seal endpoint", () => { ); const maxToken = "a".repeat(2048); - const req = makeSealRequest({ body: { token: maxToken, purpose: "jira-api" } }); + const req = makeSealRequest({ body: { token: maxToken, purpose: "jira-api-token" } }); const res = await worker.fetch(req, makeEnv()); expect(res.status).toBe(200); @@ -232,7 +232,7 @@ describe("Worker /api/proxy/seal endpoint", () => { new Response(JSON.stringify({ success: true }), { status: 200 }) ); - const req = makeSealRequest({ body: { purpose: "jira-api" } }); + const req = makeSealRequest({ body: { purpose: "jira-api-token" } }); const res = await worker.fetch(req, makeEnv()); expect(res.status).toBe(400); @@ -341,7 +341,7 @@ describe("Worker /api/proxy/seal endpoint", () => { new Response(JSON.stringify({ success: true }), { status: 200 }) ); - const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "test-purpose" } }); + const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "jira-api-token" } }); await worker.fetch(req, makeEnv()); const allLogs: Array> = []; @@ -356,7 +356,7 @@ describe("Worker /api/proxy/seal endpoint", () => { } const sealLog = allLogs.find((l) => l["event"] === "token_sealed"); expect(sealLog).toBeDefined(); - expect(sealLog!["purpose"]).toBe("test-purpose"); + expect(sealLog!["purpose"]).toBe("jira-api-token"); expect(sealLog!["token_length"]).toBe(10); // "ghp_abc123".length // Must NOT log the actual token value const allLogText = allLogs.map((l) => JSON.stringify(l)).join("\n"); From 8fedd8dc843e122ccd2e30592739828f3c947d03 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 8 Apr 2026 22:03:21 -0400 Subject: [PATCH 07/56] docs: add /api/proxy/seal endpoint to DEPLOY.md API table --- DEPLOY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DEPLOY.md b/DEPLOY.md index d5e97d16..a2f58fdf 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -75,6 +75,7 @@ wrangler secret put ALLOWED_ORIGIN |----------|--------|---------| | `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. | | `/api/health` | GET | Health check. Returns `OK`. | +| `/api/proxy/seal` | POST | Encrypt an API token for client-side storage. Requires Turnstile + session. | ### Token Storage Security From 55b70c75f6730bfbf14c1ed4c5571e5537d160d4 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 9 Apr 2026 08:37:58 -0400 Subject: [PATCH 08/56] fix(worker): pass sessionId to handleProxySeal for SC-11 audit logging --- src/worker/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index e50a207d..67bd40d6 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -187,7 +187,7 @@ function validateAndGuardProxyRoute(request: Request, env: Env): Response | null // ── Sealed-token endpoint ──────────────────────────────────────────────────── const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token", "gitlab-pat"]); -async function handleProxySeal(request: Request, env: Env): Promise { +async function handleProxySeal(request: Request, env: Env, sessionId: string): Promise { if (request.method !== "POST") { return errorResponse("method_not_allowed", 405); } @@ -248,8 +248,9 @@ async function handleProxySeal(request: Request, env: Env): Promise { return errorResponse("seal_failed", 500); } - // SC-11: log seal operations + // SC-11: log seal operations (sessionId for correlation) log("info", "token_sealed", { + sessionId, purpose, token_length: token.length, }, request); @@ -718,7 +719,7 @@ export default { // Step 5: Sealed-token endpoint if (url.pathname === "/api/proxy/seal") { - const sealResponse = await handleProxySeal(request, env); + const sealResponse = await handleProxySeal(request, env, sessionId); if (setCookie) { const headers = new Headers(sealResponse.headers); headers.set("Set-Cookie", setCookie); From 39215e464ad6170f726d24cc2a965775807b4b67 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 9 Apr 2026 08:44:28 -0400 Subject: [PATCH 09/56] fix(worker): add rate limiter error handling and invalid purpose test --- src/worker/index.ts | 14 ++++++++++++-- tests/worker/seal.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 67bd40d6..02b452e0 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -704,8 +704,18 @@ export default { const { sessionId, setCookie } = await ensureSession(request, env); // Step 4: Rate limiting using session ID as key - const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); - if (!success) { + let rateLimited = false; + try { + const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); + rateLimited = !success; + } catch (err) { + log("error", "rate_limiter_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + // Fail open — rate limiter misconfiguration should not block all proxy requests. + // Turnstile and session binding still protect the seal endpoint. + } + if (rateLimited) { log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); const rateLimitResponse = errorResponse("rate_limited", 429); const headers = new Headers(rateLimitResponse.headers); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 042897a6..cf69c7b6 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -227,6 +227,19 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("invalid_request"); }); + it("request with invalid purpose (not in VALID_PURPOSES) returns 400", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "ghp_test", purpose: "github-pat" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + it("request with missing token returns 400 with invalid_request", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true }), { status: 200 }) From a28a80de46241d2c4e98ad617cd8569cfe4691e2 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 9 Apr 2026 08:53:50 -0400 Subject: [PATCH 10/56] fix(security): adds Turnstile domains to CSP, removes dead body.code path, adds rate limiter error test --- public/_headers | 2 +- src/app/lib/proxy.ts | 2 +- tests/app/lib/proxy.test.ts | 18 ++---------------- tests/worker/seal.test.ts | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/public/_headers b/public/_headers index 994eb668..353fe0c6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - 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 ws://127.0.0.1:*; 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 + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; 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 diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index 09b7d9e4..03070cd6 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -118,7 +118,7 @@ export async function sealApiToken(token: string, purpose: string): Promise { it("throws { status, message } on 403 response", async () => { setupMockedTurnstile("turnstile-tok"); - const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ code: "turnstile_failed" }), { status: 403 }), - ); - vi.stubGlobal("fetch", mockFetch); - - await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ - status: 403, - message: "turnstile_failed", - }); - }); - - it("throws { status, message } on 403 response using error field", async () => { - setupMockedTurnstile("turnstile-tok"); - const mockFetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: "turnstile_failed" }), { status: 403 }), ); @@ -317,7 +303,7 @@ describe("sealApiToken", () => { setupMockedTurnstile("turnstile-tok"); const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ code: "rate_limited" }), { status: 429 }), + new Response(JSON.stringify({ error: "rate_limited" }), { status: 429 }), ); vi.stubGlobal("fetch", mockFetch); @@ -331,7 +317,7 @@ describe("sealApiToken", () => { setupMockedTurnstile("turnstile-tok"); const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ code: "seal_failed" }), { status: 500 }), + new Response(JSON.stringify({ error: "seal_failed" }), { status: 500 }), ); vi.stubGlobal("fetch", mockFetch); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index cf69c7b6..860d992c 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -171,6 +171,21 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(res.headers.get("Retry-After")).toBe("60"); }); + it("request proceeds when rate limiter throws (fail-open)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const rateLimiter = { limit: vi.fn().mockRejectedValue(new Error("binding unavailable")) }; + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv({ PROXY_RATE_LIMITER: rateLimiter })); + + // Should NOT be 429 or 500 — rate limiter failure is fail-open + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(json["sealed"]).toBeDefined(); + }); + // ── Input validation ────────────────────────────────────────────────────── it("request with token exceeding 2048 chars returns 400 with invalid_request", async () => { From 7a4011815f12fe448c32130b4e3a33b38ad06ecf Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 9 Apr 2026 09:11:53 -0400 Subject: [PATCH 11/56] refactor(worker): removes dead code, uses SealError class --- src/app/lib/proxy.ts | 20 +++++++++++---- src/worker/crypto.ts | 23 ------------------ src/worker/session.ts | 9 ------- tests/app/lib/proxy.test.ts | 13 +++++----- tests/worker/crypto.test.ts | 47 ------------------------------------ tests/worker/session.test.ts | 20 --------------- 6 files changed, 21 insertions(+), 111 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index 03070cd6..f820a333 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -102,6 +102,16 @@ export async function proxyFetch( }); } +export class SealError extends Error { + readonly status: number; + + constructor(status: number, code: string) { + super(code); + this.name = "SealError"; + this.status = status; + } +} + export async function sealApiToken(token: string, purpose: string): Promise { const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; const turnstileToken = await acquireTurnstileToken(siteKey ?? ""); @@ -115,14 +125,14 @@ export async function sealApiToken(token: string, purpose: string): Promise { - const current = await deriveKey(currentKey, salt, info, "sign"); - if (await verifySession(payload, signature, current)) return true; - - if (prevKey !== undefined) { - const prev = await deriveKey(prevKey, salt, info, "sign"); - return verifySession(payload, signature, prev); - } - - return false; -} - export { SEAL_SALT }; diff --git a/src/worker/session.ts b/src/worker/session.ts index 8c4e211a..98203ff6 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -130,13 +130,6 @@ export async function parseSession( } } -/** - * Returns a Set-Cookie header value that clears the session cookie. - */ -export function clearSession(): string { - return `${SESSION_COOKIE_NAME}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0`; -} - /** * Returns the existing session ID if valid, or issues a new session. * Never throws — all error paths return a value. @@ -161,5 +154,3 @@ export async function ensureSession( return { sessionId: crypto.randomUUID() }; } } - -export { SESSION_HMAC_SALT, SESSION_HMAC_INFO }; diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index ee809122..9bb5c8d7 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -285,7 +285,7 @@ describe("sealApiToken", () => { expect(result).toBe("enc:abc123"); }); - it("throws { status, message } on 403 response", async () => { + it("throws SealError on 403 response", async () => { setupMockedTurnstile("turnstile-tok"); const mockFetch = vi.fn().mockResolvedValue( @@ -293,13 +293,12 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ - status: 403, - message: "turnstile_failed", - }); + const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e); + expect(err).toBeInstanceOf(mod.SealError); + expect(err).toMatchObject({ status: 403, message: "turnstile_failed" }); }); - it("throws { status, message } on 429 response", async () => { + it("throws SealError on 429 response", async () => { setupMockedTurnstile("turnstile-tok"); const mockFetch = vi.fn().mockResolvedValue( @@ -313,7 +312,7 @@ describe("sealApiToken", () => { }); }); - it("throws { status, message } on 500 response", async () => { + it("throws SealError on 500 response", async () => { setupMockedTurnstile("turnstile-tok"); const mockFetch = vi.fn().mockResolvedValue( diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index 036964de..f94348ec 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -8,7 +8,6 @@ import { unsealTokenWithRotation, signSession, verifySession, - verifySessionWithRotation, } from "../../src/worker/crypto"; // Stable base64url-encoded 32-byte test keys (not real secrets) @@ -250,49 +249,3 @@ describe("signSession / verifySession", () => { expect(await verifySession("payload", "!!!invalid!!!", key)).toBe(false); }); }); - -describe("verifySessionWithRotation", () => { - it("verifies with current key", async () => { - const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); - const sig = await signSession("data", keyA); - const result = await verifySessionWithRotation( - "data", - sig, - KEY_A, - undefined, - "github-tracker-session-v1", - "session-hmac" - ); - expect(result).toBe(true); - }); - - it("falls back to prevKey when current key fails", async () => { - const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); - const sig = await signSession("data", keyA); - // Signed with A, try currentKey=B, prevKey=A - const result = await verifySessionWithRotation( - "data", - sig, - KEY_B, - KEY_A, - "github-tracker-session-v1", - "session-hmac" - ); - expect(result).toBe(true); - }); - - it("returns false when both keys fail", async () => { - const keyA = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); - const sig = await signSession("data", keyA); - const KEY_C = btoa("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); - const result = await verifySessionWithRotation( - "data", - sig, - KEY_B, - KEY_C, - "github-tracker-session-v1", - "session-hmac" - ); - expect(result).toBe(false); - }); -}); diff --git a/tests/worker/session.test.ts b/tests/worker/session.test.ts index b03aa472..bdea0703 100644 --- a/tests/worker/session.test.ts +++ b/tests/worker/session.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest"; import { issueSession, parseSession, - clearSession, ensureSession, type SessionEnv, } from "../../src/worker/session"; @@ -215,25 +214,6 @@ describe("parseSession", () => { }); }); -describe("clearSession", () => { - it("returns Max-Age=0", () => { - expect(clearSession()).toContain("Max-Age=0"); - }); - - it("returns __Host-session= with empty value", () => { - const result = clearSession(); - expect(result).toMatch(/^__Host-session=;/); - }); - - it("includes required security attributes", () => { - const result = clearSession(); - expect(result).toContain("Path=/"); - expect(result).toContain("Secure"); - expect(result).toContain("HttpOnly"); - expect(result).toContain("SameSite=Strict"); - }); -}); - describe("ensureSession", () => { function makeRequest(cookieHeader?: string): Request { const headers: Record = {}; From a0fc6dcd04e30517bc51b1927220925eaf1d7a8a Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 9 Apr 2026 10:57:26 -0400 Subject: [PATCH 12/56] =?UTF-8?q?fix(worker):=20addresses=20PR=20review=20?= =?UTF-8?q?findings=20=E2=80=94=20security,=20perf,=20tests,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adds Turnstile token length guard (>2048) with boundary tests - adds seal key derivation cache (_sealKeyCache Map, bounded by VALID_PURPOSES) - passes pre-parsed pathname to validateAndGuardProxyRoute (eliminates redundant URL parse) - unifies session key cache into single Map (removes duplicate getSessionHmacPrevKey) - fixes structured error logging in ensureSession catch path - removes dead options parameter from validateProxyRequest - corrects HKDF key material descriptions in CryptoEnv and DEPLOY.md - fixes test key comments to match actual decoded values - adds 14 new tests covering previously-untested paths --- DEPLOY.md | 8 +-- src/worker/crypto.ts | 4 +- src/worker/index.ts | 26 +++++--- src/worker/session.ts | 24 ++++---- src/worker/validation.ts | 6 +- tests/app/lib/proxy.test.ts | 96 ++++++++++++++++++++++++++--- tests/worker/seal.test.ts | 105 ++++++++++++++++++++++++++++---- tests/worker/session.test.ts | 15 ++++- tests/worker/turnstile.test.ts | 8 +++ tests/worker/validation.test.ts | 16 ----- 10 files changed, 238 insertions(+), 70 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index a2f58fdf..1e99a353 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -191,13 +191,13 @@ openssl rand -base64 32 # Run once per key below ### Setting secrets ```bash -wrangler secret put SESSION_KEY # HMAC key for session cookies -wrangler secret put SEAL_KEY # AES-256-GCM key for sealed tokens +wrangler secret put SESSION_KEY # HKDF input key material for session cookies +wrangler secret put SEAL_KEY # HKDF input key material for sealed tokens wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard ``` -- `SESSION_KEY`: HMAC-SHA256 key used to sign `__Host-session` cookies. Generate with `openssl rand -base64 32`. -- `SEAL_KEY`: AES-256-GCM key used to encrypt Jira/GitLab API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. +- `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`. +- `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. - `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key). - `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key). diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts index b999d275..8ba0b75c 100644 --- a/src/worker/crypto.ts +++ b/src/worker/crypto.ts @@ -1,6 +1,6 @@ export interface CryptoEnv { - SEAL_KEY: string; // base64-encoded 32-byte AES-256-GCM key - SEAL_KEY_PREV?: string; // previous key for rotation + SEAL_KEY: string; // base64-encoded HKDF input key material (32 bytes recommended) + SEAL_KEY_PREV?: string; // previous HKDF key material for rotation } // ── Base64url utilities ──────────────────────────────────────────────────── diff --git a/src/worker/index.ts b/src/worker/index.ts index 02b452e0..cf1aac3a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -152,10 +152,7 @@ function isProxyPath(pathname: string): boolean { // ── Validation gate for proxy routes ───────────────────────────────────────── // Returns a Response if rejected, null if validation passes. -function validateAndGuardProxyRoute(request: Request, env: Env): Response | null { - const url = new URL(request.url); - const pathname = url.pathname; - +function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string): Response | null { if (!isProxyPath(pathname)) return null; const origin = request.headers.get("Origin"); @@ -187,18 +184,26 @@ function validateAndGuardProxyRoute(request: Request, env: Env): Response | null // ── Sealed-token endpoint ──────────────────────────────────────────────────── const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token", "gitlab-pat"]); +// Module-level cache for derived seal keys, keyed by ":". +// SEAL_KEY is a deployment constant — safe to cache per-isolate (follows _sessionKeyCache pattern). +const _sealKeyCache = new Map(); + async function handleProxySeal(request: Request, env: Env, sessionId: string): Promise { if (request.method !== "POST") { return errorResponse("method_not_allowed", 405); } // Session + rate limiting (done by caller, sessionId passed in) - // Extract Turnstile token and verify (Step 6) + // Extract Turnstile token and verify const turnstileToken = extractTurnstileToken(request); if (!turnstileToken) { log("warn", "seal_turnstile_missing", {}, request); return errorResponse("turnstile_failed", 403); } + if (turnstileToken.length > 2048) { + log("warn", "seal_turnstile_token_too_long", { token_length: turnstileToken.length }, request); + return errorResponse("turnstile_failed", 403); + } const ip = request.headers.get("CF-Connecting-IP"); const turnstileResult = await verifyTurnstile(turnstileToken, ip, env); @@ -237,8 +242,13 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P let sealed: string; try { - // SC-8: derive key with purpose-scoped info string - const key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt"); + // SC-8: derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) + const cacheKey = env.SEAL_KEY + ":" + purpose; + let key = _sealKeyCache.get(cacheKey); + if (key === undefined) { + key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt"); + _sealKeyCache.set(cacheKey, key); + } sealed = await sealToken(token, key); } catch (err) { // SC-9: log error server-side but DO NOT include crypto error in response @@ -696,7 +706,7 @@ export default { // ── Proxy routes: validation, session, and rate limiting ───────────────── // Applies to /api/proxy/*, /api/jira/*, /api/gitlab/* // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. - const guardResponse = validateAndGuardProxyRoute(request, env); + const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname); if (guardResponse !== null) return guardResponse; if (isProxyPath(url.pathname)) { diff --git a/src/worker/session.ts b/src/worker/session.ts index 98203ff6..b7d48ccb 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -33,20 +33,14 @@ const SESSION_MAX_AGE = 28800; // 8 hours in seconds // Module-level cache for derived session HMAC keys. // SESSION_KEY is a deployment constant — safe to cache per-isolate (follows _dsnCache pattern). -let _sessionKeyCache: { raw: string; key: CryptoKey } | undefined; -let _sessionKeyPrevCache: { raw: string; key: CryptoKey } | undefined; +// Keyed by raw secret string; supports both current and previous key without duplicate logic. +const _sessionKeyCache = new Map(); async function getSessionHmacKey(raw: string): Promise { - if (_sessionKeyCache?.raw === raw) return _sessionKeyCache.key; + const cached = _sessionKeyCache.get(raw); + if (cached !== undefined) return cached; const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); - _sessionKeyCache = { raw, key }; - return key; -} - -async function getSessionHmacPrevKey(raw: string): Promise { - if (_sessionKeyPrevCache?.raw === raw) return _sessionKeyPrevCache.key; - const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); - _sessionKeyPrevCache = { raw, key }; + _sessionKeyCache.set(raw, key); return key; } @@ -116,7 +110,7 @@ export async function parseSession( const currentKey = await getSessionHmacKey(env.SESSION_KEY); let valid = await verifySession(json, signature, currentKey); if (!valid && env.SESSION_KEY_PREV !== undefined) { - const prevKey = await getSessionHmacPrevKey(env.SESSION_KEY_PREV); + const prevKey = await getSessionHmacKey(env.SESSION_KEY_PREV); valid = await verifySession(json, signature, prevKey); } if (!valid) return null; @@ -150,7 +144,11 @@ export async function ensureSession( const { cookie, sessionId } = await issueSession(env); return { sessionId, setCookie: cookie }; } catch (error) { - console.error("session_issue_failed", error); + console.error(JSON.stringify({ + worker: "github-tracker", + event: "session_issue_failed", + error: error instanceof Error ? error.message : "unknown", + })); return { sessionId: crypto.randomUUID() }; } } diff --git a/src/worker/validation.ts b/src/worker/validation.ts index 085daee6..bdfad6e9 100644 --- a/src/worker/validation.ts +++ b/src/worker/validation.ts @@ -65,8 +65,7 @@ const METHODS_REQUIRING_CONTENT_TYPE = new Set(["POST", "PUT", "PATCH"]); */ export function validateProxyRequest( request: Request, - allowedOrigin: string, - options?: { expectedContentType?: string } + allowedOrigin: string ): ValidationResult { const originResult = validateOrigin(request, allowedOrigin); if (!originResult.ok) return originResult; @@ -78,8 +77,7 @@ export function validateProxyRequest( if (!customHeaderResult.ok) return customHeaderResult; if (METHODS_REQUIRING_CONTENT_TYPE.has(request.method)) { - const expected = options?.expectedContentType ?? "application/json"; - const contentTypeResult = validateContentType(request, expected); + const contentTypeResult = validateContentType(request, "application/json"); if (!contentTypeResult.ok) return contentTypeResult; } diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 9bb5c8d7..3d270d02 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -22,16 +22,24 @@ interface MockTurnstile { _resolveToken(token: string): void; /** Trigger the error callback for the most-recently rendered widget. */ _rejectWithError(code: string): void; + /** Trigger the expired-callback for the most-recently rendered widget. */ + _triggerExpired(): void; } function makeMockTurnstile(): MockTurnstile { let _successCb: ((token: string) => void) | undefined; let _errorCb: ((code: string) => void) | undefined; + let _expiredCb: (() => void) | undefined; const mock: MockTurnstile = { - render: vi.fn((_container: HTMLElement, options: { callback?: (token: string) => void; "error-callback"?: (code: string) => void }) => { + render: vi.fn((_container: HTMLElement, options: { + callback?: (token: string) => void; + "error-callback"?: (code: string) => void; + "expired-callback"?: () => void; + }) => { _successCb = options.callback; _errorCb = options["error-callback"]; + _expiredCb = options["expired-callback"]; return "widget-id-1"; }), execute: vi.fn(), @@ -43,6 +51,9 @@ function makeMockTurnstile(): MockTurnstile { _rejectWithError(code: string) { _errorCb?.(code); }, + _triggerExpired() { + _expiredCb?.(); + }, }; return mock; @@ -115,6 +126,21 @@ describe("proxyFetch", () => { expect(headers["cf-turnstile-response"]).toBe("tok123"); }); + it("merges Headers instance caller headers without dropping defaults", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal", { + headers: new Headers({ "cf-turnstile-response": "tok" }), + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Requested-With"]).toBe("fetch"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["cf-turnstile-response"]).toBe("tok"); + }); + it("passes the path to fetch unchanged", async () => { const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal("fetch", mockFetch); @@ -229,6 +255,47 @@ describe("acquireTurnstileToken", () => { await expect(tokenPromise).rejects.toThrow("Turnstile error: invalid-input-response"); }); + + it("rejects when Turnstile fires expired-callback", async () => { + const mockTurnstile = makeMockTurnstile(); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + const tokenPromise = mod.acquireTurnstileToken("test-site-key"); + + await Promise.resolve(); + await Promise.resolve(); + + mockTurnstile._triggerExpired(); + + await expect(tokenPromise).rejects.toThrow("Turnstile token expired before submission"); + }); + + it("rejects when the Turnstile script fails to load (onerror)", async () => { + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onerror: (() => void) | null }).onerror?.(); + return node; + } + return node; + }); + + await expect(mod.acquireTurnstileToken("test-site-key")).rejects.toThrow( + "Failed to load Turnstile script", + ); + }); }); // ── sealApiToken tests ──────────────────────────────────────────────────────── @@ -306,10 +373,9 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ - status: 429, - message: "rate_limited", - }); + const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e); + expect(err).toBeInstanceOf(mod.SealError); + expect(err).toMatchObject({ status: 429, message: "rate_limited" }); }); it("throws SealError on 500 response", async () => { @@ -320,10 +386,9 @@ describe("sealApiToken", () => { ); vi.stubGlobal("fetch", mockFetch); - await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({ - status: 500, - message: "seal_failed", - }); + const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e); + expect(err).toBeInstanceOf(mod.SealError); + expect(err).toMatchObject({ status: 500, message: "seal_failed" }); }); it("rejects when fetch throws a network error", async () => { @@ -335,6 +400,19 @@ describe("sealApiToken", () => { await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toThrow("Failed to fetch"); }); + it("throws SealError with 'unknown_error' when error response body is non-JSON", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response("Service Unavailable", { status: 503 }), + ); + vi.stubGlobal("fetch", mockFetch); + + const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e); + expect(err).toBeInstanceOf(mod.SealError); + expect(err).toMatchObject({ status: 503, message: "unknown_error" }); + }); + it("includes cf-turnstile-response header in POST body", async () => { setupMockedTurnstile("expected-turnstile-token"); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 860d992c..3fb16e8c 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -3,10 +3,10 @@ import worker, { type Env } from "../../src/worker/index"; const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; -// Valid base64url-encoded 32-byte keys for testing -// "test-session-key-32bytes-padding!" base64-encoded +// Base64-encoded test keys for testing (HKDF accepts any length input key material) +// "test-session-key" base64-encoded const TEST_SESSION_KEY = "dGVzdC1zZXNzaW9uLWtleQ=="; -// "test-seal-key-32bytes-padding!!!!" base64-encoded +// "test-seal-key" base64-encoded const TEST_SEAL_KEY = "dGVzdC1zZWFsLWtleQ=="; function makeEnv(overrides: Partial = {}): Env { @@ -56,12 +56,15 @@ function makeSealRequest(options: { describe("Worker /api/proxy/seal endpoint", () => { let originalFetch: typeof globalThis.fetch; + let consoleSpies: { info: ReturnType; warn: ReturnType; error: ReturnType }; beforeEach(() => { originalFetch = globalThis.fetch; - vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); + consoleSpies = { + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; }); afterEach(() => { @@ -154,6 +157,30 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("turnstile_failed"); }); + it("request with oversized Turnstile header (>2048 chars) returns 403 with turnstile_failed", async () => { + const oversizedToken = "a".repeat(2049); + const req = makeSealRequest({ turnstileToken: oversizedToken }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("turnstile_failed"); + }); + + it("request with Turnstile header exactly 2048 chars is not rejected by length guard", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const maxToken = "a".repeat(2048); + const req = makeSealRequest({ turnstileToken: maxToken }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + }); + // ── Rate limiting ───────────────────────────────────────────────────────── it("request exceeding rate limit returns 429 with rate_limited and Retry-After header", async () => { @@ -305,6 +332,65 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); }); + // ── Non-POST method rejection ───────────────────────────────────────────── + + it("GET request to /api/proxy/seal returns 405 with method_not_allowed", async () => { + const req = makeSealRequest({ method: "GET" }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(405); + const json = await res.json() as Record; + expect(json["error"]).toBe("method_not_allowed"); + }); + + it("successful POST to /api/proxy/seal does not set Access-Control-Allow-Origin", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + + // ── Unimplemented proxy routes ──────────────────────────────────────────── + + it("valid POST to /api/jira/issues falls through to 404 with not_found", async () => { + const req = new Request("https://gh.gordoncode.dev/api/jira/issues", { + method: "POST", + headers: { + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(404); + const json = await res.json() as Record; + expect(json["error"]).toBe("not_found"); + }); + + it("valid POST to /api/gitlab/token falls through to 404 with not_found", async () => { + const req = new Request("https://gh.gordoncode.dev/api/gitlab/token", { + method: "POST", + headers: { + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(404); + const json = await res.json() as Record; + expect(json["error"]).toBe("not_found"); + }); + // ── Session cookie issuance ─────────────────────────────────────────────── it("first request issues a session cookie in Set-Cookie", async () => { @@ -360,11 +446,6 @@ describe("Worker /api/proxy/seal endpoint", () => { // ── SC-11: seal operation logging ───────────────────────────────────────── it("successful seal logs token_sealed event with purpose and token_length", async () => { - const consoleSpy = { - info: vi.spyOn(console, "info"), - warn: vi.spyOn(console, "warn"), - error: vi.spyOn(console, "error"), - }; globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true }), { status: 200 }) ); @@ -373,7 +454,7 @@ describe("Worker /api/proxy/seal endpoint", () => { await worker.fetch(req, makeEnv()); const allLogs: Array> = []; - for (const [, spy] of Object.entries(consoleSpy)) { + for (const [, spy] of Object.entries(consoleSpies)) { for (const call of spy.mock.calls) { try { allLogs.push(JSON.parse(call[0] as string) as Record); diff --git a/tests/worker/session.test.ts b/tests/worker/session.test.ts index bdea0703..6debf210 100644 --- a/tests/worker/session.test.ts +++ b/tests/worker/session.test.ts @@ -126,7 +126,6 @@ describe("parseSession", () => { it("returns null for expired session", async () => { const env = makeEnv(); // Mock Date.now to issue a session in the past - const realNow = Date.now; const pastTime = Date.now() - 9 * 3600 * 1000; // 9 hours ago (> 8h SESSION_MAX_AGE) vi.spyOn(Date, "now").mockReturnValue(pastTime); const { cookie } = await issueSession(env); @@ -138,7 +137,6 @@ describe("parseSession", () => { env ); expect(result).toBeNull(); - void realNow; // suppress unused warning }); it("accepts a session issued 1 second ago (clock skew)", async () => { @@ -262,4 +260,17 @@ describe("ensureSession", () => { const result = await ensureSession(req, makeEnv()); expect(result.setCookie).toBeDefined(); }); + + it("catch path: returns fallback sessionId (no setCookie) when issueSession throws", async () => { + // "!!bad!!" is not valid base64url — fromBase64Url → atob throws, + // which propagates through getSessionHmacKey → issueSession, exercising the catch block. + const env = makeEnv({ SESSION_KEY: "!!bad!!" }); + const req = makeRequest(); + const result = await ensureSession(req, env); + expect(result.sessionId).toBeTruthy(); + expect(result.sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(result.setCookie).toBeUndefined(); + }); }); diff --git a/tests/worker/turnstile.test.ts b/tests/worker/turnstile.test.ts index 079fb048..48388e81 100644 --- a/tests/worker/turnstile.test.ts +++ b/tests/worker/turnstile.test.ts @@ -63,6 +63,14 @@ describe("verifyTurnstile", () => { expect(result).toEqual({ success: false, errorCodes: ["network-error"] }); }); + it("returns timeout errorCode when fetch is aborted (AbortError)", async () => { + const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" }); + mockFetch.mockRejectedValueOnce(abortError); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: false, errorCodes: ["timeout"] }); + }); + it("returns network-error when response body is not valid JSON", async () => { mockFetch.mockResolvedValueOnce( new Response("not-json", { diff --git a/tests/worker/validation.test.ts b/tests/worker/validation.test.ts index 7ed768fe..dff7644e 100644 --- a/tests/worker/validation.test.ts +++ b/tests/worker/validation.test.ts @@ -270,22 +270,6 @@ describe("validateProxyRequest", () => { expect(result).toEqual({ ok: false, code: "invalid_content_type", status: 415 }); }); - it("uses custom expectedContentType when provided", () => { - const req = makeRequest({ - method: "POST", - headers: { - Origin: ALLOWED_ORIGIN, - "Sec-Fetch-Site": "same-origin", - "X-Requested-With": "fetch", - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - const result = validateProxyRequest(req, ALLOWED_ORIGIN, { - expectedContentType: "application/x-www-form-urlencoded", - }); - expect(result).toEqual({ ok: true }); - }); - it("short-circuits on first failure (origin checked before fetch metadata)", () => { // Both Origin and Sec-Fetch-Site are wrong — should fail on origin_mismatch const req = makeRequest({ From 5e6f4440978532c945968c1e24023c977cfe957c Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 12:28:26 -0400 Subject: [PATCH 13/56] fix(turnstile): harden widget lifecycle - Sets retry: 'never' on widget options to prevent setTimeout leak when turnstile.remove() is called during a retry cycle - Wraps render+execute in turnstile.ready() per Cloudflare docs to guard against race conditions with script loading - Hoists widgetId to outer scope so timeout cleanup works across the ready() callback boundary - Adds ready() and retry to Turnstile type declarations - Adds ready mock to test fixture --- src/app/lib/proxy.ts | 61 +++++++++++++++++++++---------------- src/types/turnstile.d.ts | 2 ++ tests/app/lib/proxy.test.ts | 2 ++ 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index f820a333..ffee19ff 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -34,47 +34,54 @@ export async function acquireTurnstileToken(siteKey: string): Promise { return new Promise((resolve, reject) => { let settled = false; + let currentWidgetId: string | null = null; const container = document.createElement("div"); container.style.cssText = "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999; min-width: 300px; min-height: 65px;"; document.body.appendChild(container); - const cleanup = (widgetId: string) => { - window.turnstile.remove(widgetId); + const cleanup = () => { + if (currentWidgetId !== null) { + window.turnstile.remove(currentWidgetId); + } container.remove(); }; - const widgetId = window.turnstile.render(container, { - sitekey: siteKey, - size: "invisible", - execution: "execute", - callback: (token: string) => { - if (settled) return; - settled = true; - cleanup(widgetId); - resolve(token); - }, - "error-callback": (errorCode: string) => { - if (settled) return; - settled = true; - cleanup(widgetId); - reject(new Error(`Turnstile error: ${errorCode}`)); - }, - "expired-callback": () => { - if (settled) return; - settled = true; - cleanup(widgetId); - reject(new Error("Turnstile token expired before submission")); - }, + window.turnstile.ready(() => { + const widgetId = window.turnstile.render(container, { + sitekey: siteKey, + size: "invisible", + execution: "execute", + retry: "never", + callback: (token: string) => { + if (settled) return; + settled = true; + cleanup(); + resolve(token); + }, + "error-callback": (errorCode: string) => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Turnstile error: ${errorCode}`)); + }, + "expired-callback": () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile token expired before submission")); + }, + }); + + currentWidgetId = widgetId; + window.turnstile.execute(widgetId); }); - window.turnstile.execute(widgetId); - setTimeout(() => { if (settled) return; settled = true; - cleanup(widgetId); + cleanup(); reject(new Error("Turnstile challenge timed out after 30 seconds")); }, 30_000); }); diff --git a/src/types/turnstile.d.ts b/src/types/turnstile.d.ts index a7472f37..c7eb1acb 100644 --- a/src/types/turnstile.d.ts +++ b/src/types/turnstile.d.ts @@ -5,6 +5,7 @@ interface TurnstileRenderOptions { sitekey: string; size?: "normal" | "compact" | "invisible" | "flexible"; execution?: "render" | "execute"; + retry?: "auto" | "never"; callback?: (token: string) => void; "error-callback"?: (errorCode: string) => void; "expired-callback"?: () => void; @@ -12,6 +13,7 @@ interface TurnstileRenderOptions { } interface Turnstile { + ready(callback: () => void): void; render(container: HTMLElement | string, options: TurnstileRenderOptions): string; execute(widgetId: string): void; remove(widgetId: string): void; diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 3d270d02..897b1ae4 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -14,6 +14,7 @@ async function loadModule() { // ── Mock Turnstile factory ──────────────────────────────────────────────────── interface MockTurnstile { + ready: ReturnType; render: ReturnType; execute: ReturnType; remove: ReturnType; @@ -32,6 +33,7 @@ function makeMockTurnstile(): MockTurnstile { let _expiredCb: (() => void) | undefined; const mock: MockTurnstile = { + ready: vi.fn((cb: () => void) => cb()), render: vi.fn((_container: HTMLElement, options: { callback?: (token: string) => void; "error-callback"?: (code: string) => void; From 6a9773a1445676e930a3bee583c78187b7ff2aec Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 13:18:08 -0400 Subject: [PATCH 14/56] fix(turnstile): hardens widget lifecycle edge cases - Adds settled guard at start of ready() callback to prevent widget creation after timeout fires - Wraps render+execute in try/catch for immediate rejection on render failure instead of 30s hang - Wires timeout-callback to reject immediately on Turnstile internal challenge timeout - Makes X-Requested-With header non-overridable in proxyFetch - Adds tests: timeout-callback, X-Requested-With non-override, retry:never assertion, ready() call assertion --- src/app/lib/proxy.ts | 71 ++++++++++++++++++++++++------------- tests/app/lib/proxy.test.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index ffee19ff..f260abd0 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -49,30 +49,47 @@ export async function acquireTurnstileToken(siteKey: string): Promise { }; window.turnstile.ready(() => { - const widgetId = window.turnstile.render(container, { - sitekey: siteKey, - size: "invisible", - execution: "execute", - retry: "never", - callback: (token: string) => { - if (settled) return; - settled = true; - cleanup(); - resolve(token); - }, - "error-callback": (errorCode: string) => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error(`Turnstile error: ${errorCode}`)); - }, - "expired-callback": () => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error("Turnstile token expired before submission")); - }, - }); + if (settled) return; + + let widgetId: string; + try { + widgetId = window.turnstile.render(container, { + sitekey: siteKey, + size: "invisible", + execution: "execute", + retry: "never", + callback: (token: string) => { + if (settled) return; + settled = true; + cleanup(); + resolve(token); + }, + "error-callback": (errorCode: string) => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Turnstile error: ${errorCode}`)); + }, + "expired-callback": () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile token expired before submission")); + }, + "timeout-callback": () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile challenge timed out")); + }, + }); + } catch (err) { + if (settled) return; + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error("Turnstile render failed")); + return; + } currentWidgetId = widgetId; window.turnstile.execute(widgetId); @@ -101,7 +118,11 @@ export async function proxyFetch( ? Object.fromEntries(options.headers.entries()) : (options?.headers as Record | undefined) ?? {}; - const mergedHeaders = { ...defaultHeaders, ...callerHeaders }; + const mergedHeaders = { + ...defaultHeaders, + ...callerHeaders, + "X-Requested-With": "fetch", + }; return fetch(path, { ...options, diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 897b1ae4..13f6eddd 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -25,12 +25,15 @@ interface MockTurnstile { _rejectWithError(code: string): void; /** Trigger the expired-callback for the most-recently rendered widget. */ _triggerExpired(): void; + /** Trigger the timeout-callback for the most-recently rendered widget. */ + _triggerTimeout(): void; } function makeMockTurnstile(): MockTurnstile { let _successCb: ((token: string) => void) | undefined; let _errorCb: ((code: string) => void) | undefined; let _expiredCb: (() => void) | undefined; + let _timeoutCb: (() => void) | undefined; const mock: MockTurnstile = { ready: vi.fn((cb: () => void) => cb()), @@ -38,10 +41,12 @@ function makeMockTurnstile(): MockTurnstile { callback?: (token: string) => void; "error-callback"?: (code: string) => void; "expired-callback"?: () => void; + "timeout-callback"?: () => void; }) => { _successCb = options.callback; _errorCb = options["error-callback"]; _expiredCb = options["expired-callback"]; + _timeoutCb = options["timeout-callback"]; return "widget-id-1"; }), execute: vi.fn(), @@ -56,6 +61,9 @@ function makeMockTurnstile(): MockTurnstile { _triggerExpired() { _expiredCb?.(); }, + _triggerTimeout() { + _timeoutCb?.(); + }, }; return mock; @@ -143,6 +151,19 @@ describe("proxyFetch", () => { expect(headers["cf-turnstile-response"]).toBe("tok"); }); + it("X-Requested-With cannot be overridden by callers", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", mockFetch); + + await mod.proxyFetch("/api/proxy/seal", { + headers: { "X-Requested-With": "malicious" }, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Requested-With"]).toBe("fetch"); + }); + it("passes the path to fetch unchanged", async () => { const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal("fetch", mockFetch); @@ -230,6 +251,12 @@ describe("acquireTurnstileToken", () => { const token = await tokenPromise; expect(token).toBe("test-token-abc"); + + expect(mockTurnstile.ready).toHaveBeenCalledOnce(); + expect(mockTurnstile.render).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ retry: "never" }), + ); }); it("rejects when Turnstile fires error-callback", async () => { @@ -284,6 +311,32 @@ describe("acquireTurnstileToken", () => { await expect(tokenPromise).rejects.toThrow("Turnstile token expired before submission"); }); + it("rejects when Turnstile fires timeout-callback", async () => { + const mockTurnstile = makeMockTurnstile(); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + const tokenPromise = mod.acquireTurnstileToken("test-site-key"); + + await Promise.resolve(); + await Promise.resolve(); + + mockTurnstile._triggerTimeout(); + + await expect(tokenPromise).rejects.toThrow("Turnstile challenge timed out"); + }); + it("rejects when the Turnstile script fails to load (onerror)", async () => { vi.spyOn(document.head, "appendChild").mockImplementation((node) => { const el = node as HTMLScriptElement; From 721c63ddbdd09500ac73ed5ff752a52674bed506 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 13:22:24 -0400 Subject: [PATCH 15/56] fix(turnstile): addresses adversarial review findings - Clears 30s setTimeout in cleanup() to prevent timer leak on normal promise resolution - Moves execute() inside try/catch alongside render() so both throw paths reject immediately - Wraps turnstile.remove() in try/catch inside cleanup() to prevent remove() failures from blocking reject() - Adds test for render() throwing synchronously --- src/app/lib/proxy.ts | 18 ++++++++++-------- tests/app/lib/proxy.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index f260abd0..f088d2a2 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -35,6 +35,7 @@ export async function acquireTurnstileToken(siteKey: string): Promise { return new Promise((resolve, reject) => { let settled = false; let currentWidgetId: string | null = null; + let timeoutId: ReturnType | undefined; const container = document.createElement("div"); container.style.cssText = @@ -42,8 +43,12 @@ export async function acquireTurnstileToken(siteKey: string): Promise { document.body.appendChild(container); const cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } if (currentWidgetId !== null) { - window.turnstile.remove(currentWidgetId); + try { window.turnstile.remove(currentWidgetId); } catch { /* widget already gone */ } } container.remove(); }; @@ -51,9 +56,8 @@ export async function acquireTurnstileToken(siteKey: string): Promise { window.turnstile.ready(() => { if (settled) return; - let widgetId: string; try { - widgetId = window.turnstile.render(container, { + const widgetId = window.turnstile.render(container, { sitekey: siteKey, size: "invisible", execution: "execute", @@ -83,19 +87,17 @@ export async function acquireTurnstileToken(siteKey: string): Promise { reject(new Error("Turnstile challenge timed out")); }, }); + currentWidgetId = widgetId; + window.turnstile.execute(widgetId); } catch (err) { if (settled) return; settled = true; cleanup(); reject(err instanceof Error ? err : new Error("Turnstile render failed")); - return; } - - currentWidgetId = widgetId; - window.turnstile.execute(widgetId); }); - setTimeout(() => { + timeoutId = setTimeout(() => { if (settled) return; settled = true; cleanup(); diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 13f6eddd..959836fe 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -337,6 +337,30 @@ describe("acquireTurnstileToken", () => { await expect(tokenPromise).rejects.toThrow("Turnstile challenge timed out"); }); + it("rejects immediately when turnstile.render() throws", async () => { + const mockTurnstile = makeMockTurnstile(); + mockTurnstile.render.mockImplementation(() => { + throw new Error("Invalid sitekey"); + }); + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + await expect(mod.acquireTurnstileToken("test-site-key")).rejects.toThrow( + "Invalid sitekey", + ); + }); + it("rejects when the Turnstile script fails to load (onerror)", async () => { vi.spyOn(document.head, "appendChild").mockImplementation((node) => { const el = node as HTMLScriptElement; From 28104b1dfb69235515f628537e425c43968f96c8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 13:25:09 -0400 Subject: [PATCH 16/56] fix(turnstile): moves setTimeout above ready() call - Ensures timeoutId is assigned before ready() fires so cleanup() can clear the timer even when the callback resolves synchronously --- src/app/lib/proxy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index f088d2a2..d5773dd8 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -53,6 +53,13 @@ export async function acquireTurnstileToken(siteKey: string): Promise { container.remove(); }; + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile challenge timed out after 30 seconds")); + }, 30_000); + window.turnstile.ready(() => { if (settled) return; @@ -96,13 +103,6 @@ export async function acquireTurnstileToken(siteKey: string): Promise { reject(err instanceof Error ? err : new Error("Turnstile render failed")); } }); - - timeoutId = setTimeout(() => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error("Turnstile challenge timed out after 30 seconds")); - }, 30_000); }); } From e1c993067c7e490966d4fdc792e40317cf05524d Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 14:29:50 -0400 Subject: [PATCH 17/56] feat(worker): adds KAT, fuzz tests, timing-safe verify - adds HKDF-SHA256 KAT (RFC 5869 A.1) and AES-256-GCM KAT (McGrew-Viega TC14) - adds 6 property-based fuzz tests via @fast-check/vitest - switches verifySession to SHA-256 pre-hash + timingSafeEqual (CF Workers) - adds SubtleCrypto.timingSafeEqual type augmentation - fixes test key construction to produce actual 32-byte keys --- package.json | 1 + pnpm-lock.yaml | 26 ++++++ src/worker/crypto.ts | 18 ++++- src/worker/worker-types.d.ts | 16 ++++ tests/worker/crypto.test.ts | 149 ++++++++++++++++++++++++++++++++++- 5 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 src/worker/worker-types.d.ts diff --git a/package.json b/package.json index 178a0bed..e38fc371 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@amiceli/vitest-cucumber": "6.3.0", "@cloudflare/vite-plugin": "1.30.1", "@cloudflare/vitest-pool-workers": "0.13.4", + "@fast-check/vitest": "^0.4.0", "@playwright/test": "1.58.2", "@solidjs/testing-library": "0.8.10", "@tailwindcss/vite": "4.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37471151..328633f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@cloudflare/vitest-pool-workers': specifier: 0.13.4 version: 0.13.4(@vitest/runner@4.1.1)(@vitest/snapshot@4.1.1)(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) + '@fast-check/vitest': + specifier: ^0.4.0 + version: 0.4.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -451,6 +454,11 @@ packages: cpu: [x64] os: [win32] + '@fast-check/vitest@0.4.0': + resolution: {integrity: sha512-uv/x7EyT9/fRM0oxNP2myhxHtB1pZyHYMMLVoBGFff57cyINSGftPf3ZhqNzww7ajn/ufr/Bx6OPXX/TUFezmQ==} + peerDependencies: + vitest: ^4.1.0 + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1576,6 +1584,10 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fast-check@4.6.0: + resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} + engines: {node: '>=12.17.0'} + fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} @@ -1990,6 +2002,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -2739,6 +2754,11 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@fast-check/vitest@0.4.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': + dependencies: + fast-check: 4.6.0 + vitest: 4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3750,6 +3770,10 @@ snapshots: fake-indexeddb@6.2.5: {} + fast-check@4.6.0: + dependencies: + pure-rand: 8.4.0 + fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} @@ -4086,6 +4110,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pure-rand@8.4.0: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts index 8ba0b75c..77619209 100644 --- a/src/worker/crypto.ts +++ b/src/worker/crypto.ts @@ -178,7 +178,12 @@ export async function signSession( } /** - * Verifies an HMAC-SHA256 signature. Uses crypto.subtle.verify (timing-safe). + * Verifies an HMAC-SHA256 signature using crypto.subtle.timingSafeEqual + * (Cloudflare Workers extension) for an explicit constant-time guarantee. + * + * Both inputs are hashed to SHA-256 before comparison so timingSafeEqual + * always receives equal-length buffers — no early-return length guard needed. + * This follows Cloudflare's recommended pattern for timing-attack protection. */ export async function verifySession( payload: string, @@ -194,7 +199,16 @@ export async function verifySession( const payloadBytes = new TextEncoder().encode(payload); try { - return await crypto.subtle.verify("HMAC", key, sigBytes.buffer as ArrayBuffer, payloadBytes); + const expected = new Uint8Array( + await crypto.subtle.sign("HMAC", key, payloadBytes) + ); + // Hash both to fixed 32 bytes so timingSafeEqual never sees mismatched + // lengths and the comparison is unconditionally constant-time. + const [hashA, hashB] = await Promise.all([ + crypto.subtle.digest("SHA-256", sigBytes.buffer as ArrayBuffer), + crypto.subtle.digest("SHA-256", expected.buffer as ArrayBuffer), + ]); + return crypto.subtle.timingSafeEqual(hashA, hashB); } catch { return false; } diff --git a/src/worker/worker-types.d.ts b/src/worker/worker-types.d.ts new file mode 100644 index 00000000..375cd5df --- /dev/null +++ b/src/worker/worker-types.d.ts @@ -0,0 +1,16 @@ +// Cloudflare Workers non-standard SubtleCrypto extensions. +// See https://developers.cloudflare.com/workers/runtime-apis/web-crypto/ + +interface SubtleCrypto { + /** + * Compares two buffers in constant time, preventing timing attacks. + * + * Both buffers MUST have the same byte length — hash both inputs with + * SHA-256 first so lengths are always equal. See the Cloudflare Workers + * timing-attack protection example for the recommended pattern. + * + * @throws {TypeError} if a.byteLength !== b.byteLength + * @see https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/ + */ + timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; +} diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index f94348ec..3f35153d 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { test, fc } from "@fast-check/vitest"; import { toBase64Url, fromBase64Url, @@ -10,9 +11,10 @@ import { verifySession, } from "../../src/worker/crypto"; -// Stable base64url-encoded 32-byte test keys (not real secrets) -const KEY_A = btoa("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -const KEY_B = btoa("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +// 32-byte test keys as base64url (not real secrets) +// 0x41 × 32 = "AAAA..." and 0x42 × 32 = "BBBB..." +const KEY_A = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; +const KEY_B = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI"; describe("toBase64Url / fromBase64Url", () => { it("round-trips arbitrary bytes", () => { @@ -248,4 +250,145 @@ describe("signSession / verifySession", () => { const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); expect(await verifySession("payload", "!!!invalid!!!", key)).toBe(false); }); + + it("returns false for valid base64url signature of wrong byte length", async () => { + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + // 31 bytes — valid base64url, but shorter than 32-byte HMAC-SHA256 output + const shortSig = toBase64Url(new Uint8Array(31)); + expect(await verifySession("payload-data", shortSig, key)).toBe(false); + // 33 bytes — valid base64url, longer than expected + const longSig = toBase64Url(new Uint8Array(33)); + expect(await verifySession("payload-data", longSig, key)).toBe(false); + }); +}); + +// ── Known-Answer Tests (KAT) ───────────────────────────────────────────── +// These validate the underlying Web Crypto runtime against published +// reference outputs, catching implementation bugs that round-trip tests miss. + +/** Convert a hex string to Uint8Array. Throws on odd-length or invalid hex. */ +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error(`fromHex: odd-length string (${hex.length})`); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + const byte = parseInt(hex.slice(i, i + 2), 16); + if (Number.isNaN(byte)) throw new Error(`fromHex: invalid hex at position ${i}: "${hex.slice(i, i + 2)}"`); + bytes[i / 2] = byte; + } + return bytes; +} + +/** Convert Uint8Array to lowercase hex string. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +describe("HKDF-SHA256 known-answer test (RFC 5869 Appendix A.1)", () => { + it("deriveBits matches published OKM", async () => { + // RFC 5869 Appendix A, Test Case 1 + const ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); // 22 octets + const salt = fromHex("000102030405060708090a0b0c"); // 13 octets + const info = fromHex("f0f1f2f3f4f5f6f7f8f9"); // 10 octets + const expectedOkm = + "3cb25f25faacd57a90434f64d0362f2a" + + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + + "34007208d5b887185865"; // 42 octets + + const keyMaterial = await crypto.subtle.importKey( + "raw", + ikm.buffer as ArrayBuffer, + { name: "HKDF" }, + false, + ["deriveBits"] + ); + const okm = new Uint8Array( + await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: salt.buffer as ArrayBuffer, info: info.buffer as ArrayBuffer }, + keyMaterial, + 42 * 8 // length in bits + ) + ); + + expect(toHex(okm)).toBe(expectedOkm); + }); +}); + +describe("AES-256-GCM known-answer test (McGrew-Viega Test Case 14)", () => { + it("encrypt with zero key/IV/empty plaintext produces published tag", async () => { + // GCM spec Test Case 14: 256-bit zero key, 96-bit zero IV, empty plaintext, no AAD + const key = await crypto.subtle.importKey( + "raw", + new Uint8Array(32).buffer as ArrayBuffer, // 32 zero bytes + { name: "AES-GCM" }, + false, + ["encrypt"] + ); + const iv = new Uint8Array(12); // 12 zero bytes + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new Uint8Array(0)) + ); + + // Empty plaintext → output is just the 128-bit authentication tag + expect(ciphertext.length).toBe(16); + expect(toHex(ciphertext)).toBe("530f8afbc74536b9a963b4f1c4cb738b"); + }); +}); + +// ── Property-based tests (fast-check) ───────────────────────────────────── +// These fuzz the base64url codec and seal/unseal paths with hundreds of +// random inputs per run, catching edge cases that specific test cases miss. + +describe("property-based tests", () => { + test.prop([fc.uint8Array({ minLength: 0, maxLength: 4096 })])( + "base64url round-trips arbitrary byte arrays", + (data) => { + const encoded = toBase64Url(data); + const decoded = fromBase64Url(encoded); + expect(decoded).toEqual(data); + } + ); + + test.prop([fc.uint8Array({ minLength: 1, maxLength: 1024 })])( + "base64url output contains only URL-safe characters", + (data) => { + const encoded = toBase64Url(data); + expect(encoded).toMatch(/^[A-Za-z0-9_-]*$/); + } + ); + + test.prop([fc.string({ minLength: 0, maxLength: 256 })])( + "fromBase64Url either throws or returns bytes that re-encode to the same canonical form", + (input) => { + let decoded: Uint8Array; + try { + decoded = fromBase64Url(input); + } catch { + return; // throws on invalid base64 — acceptable + } + // If decode succeeded, re-encoding must produce valid base64url + const reencoded = toBase64Url(decoded); + expect(reencoded).toMatch(/^[A-Za-z0-9_-]*$/); + // And decoding that must give the same bytes + expect(fromBase64Url(reencoded)).toEqual(decoded); + } + ); + + test.prop([fc.string({ minLength: 0, maxLength: 2048 })])( + "sealToken/unsealToken round-trips arbitrary strings", + async (plaintext) => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const sealed = await sealToken(plaintext, key); + const unsealed = await unsealToken(sealed, key); + expect(unsealed).toBe(plaintext); + } + ); + + test.prop([fc.string({ minLength: 1, maxLength: 512 })])( + "unsealToken returns null for arbitrary garbage without crashing", + async (garbage) => { + const key = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key", "encrypt"); + const result = await unsealToken(garbage, key); + expect(result).toBeNull(); + } + ); }); From 9a237568437ac31b7146277b939ffe9062c28797 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 20:57:41 -0400 Subject: [PATCH 18/56] feat(worker): adds fail-fast hardening to tunnel and proxy endpoints - Extracts createIpRateLimiter factory from token exchange rate limiter - Adds IP rate limiting to Sentry tunnel (15/min), CSP report (15/min) - Adds strict origin check to Sentry tunnel via validateOrigin - Adds soft origin check to CSP report (allows absent Origin) - Adds Content-Length pre-check before body reads on both tunnels - Adds IP pre-gate (60/min) before ensureSession on proxy routes - Removes dead GitLab route, purpose, and test references - Documents tunnel security model in DEPLOY.md - Migrates Sentry tests to dedicated sentry-tunnel.test.ts - Extracts shared test helpers to tests/worker/helpers.ts --- DEPLOY.md | 33 ++- src/worker/index.ts | 149 +++++++--- src/worker/validation.ts | 4 + tests/worker/crypto.test.ts | 2 +- tests/worker/csp-report.test.ts | 133 ++++++++- tests/worker/helpers.ts | 28 ++ tests/worker/oauth.test.ts | 258 +---------------- tests/worker/seal.test.ts | 124 ++++++-- tests/worker/sentry-tunnel.test.ts | 441 +++++++++++++++++++++++++++++ 9 files changed, 850 insertions(+), 322 deletions(-) create mode 100644 tests/worker/helpers.ts create mode 100644 tests/worker/sentry-tunnel.test.ts diff --git a/DEPLOY.md b/DEPLOY.md index 1e99a353..e4fb6e13 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -92,6 +92,37 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i - `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards) - No `Access-Control-Allow-Credentials` header (OAuth App uses no cookies) +### Tunnel Endpoint Security + +The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive untrusted browser data and forward it to Sentry. They are hardened with layered fail-fast guards: + +**Layered defense model (applied in order):** +1. **IP rate limit** (catches all abuse including curl — headers are irrelevant) +2. **Origin check** (catches cross-origin browser abuse and lazy curl scripts) +3. **DSN/project validation** (catches misdirected requests) + +Content-Length pre-check fires between origin check and DSN validation but is an optimization gate, not a security layer (see Content-Length section below). + +**Per-endpoint IP rate limits (in-memory, per-isolate):** +- Token exchange (`/api/oauth/token`): 10 requests/min per IP +- Sentry tunnel (`/api/error-reporting`): 15 requests/min per IP +- CSP report tunnel (`/api/csp-report`): 15 requests/min per IP +- Proxy pre-gate (`/api/proxy/*`, `/api/jira/*`): 60 requests/min per IP (complements CF rate limiter binding) + +Note: these counters are in-memory and do not survive isolate restarts. A fresh isolate gets a clean slate. CF isolates are short-lived, so this gates burst abuse within a single isolate lifetime — the intended use case. + +**Origin check behavior:** +- Sentry tunnel (`/api/error-reporting`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. The Sentry SDK always includes `Origin` in its `fetch()` calls from our SPA. +- CSP report tunnel (`/api/csp-report`): **soft** — allows absent Origin (browser CSP reports sent via the Reporting API may omit Origin), rejects only if Origin is present and does not match `ALLOWED_ORIGIN`. + +Note: Origin and Sec-Fetch-Site headers can be spoofed by programmatic clients (curl, scripts). IP rate limiting is the primary defense; origin checks are defense-in-depth. + +**Content-Length pre-check:** +Both tunnel endpoints check the `Content-Length` header before reading the request body as an optimization. If the declared size exceeds the per-endpoint maximum, the request is rejected with 413 without buffering the body. The post-read size check remains the authoritative guard — Content-Length can be absent (chunked transfer, passes through) or spoofed (attacker declares small size but sends large body, caught by the post-read check). + +**CSP report fan-out amplification:** +A single POST to `/api/csp-report` can contain up to 20 individual violation reports (via the Reporting API batch format), each triggering a separate outbound subrequest to Sentry's security endpoint. With the 15/min rate limit, worst case is 15 × 20 = **300 outbound subrequests per minute per IP**. This is bounded by the rate limiter on inbound requests and the 20-report cap enforced in code (`scrubbedPayloads.slice(0, 20)`). + ## Local Development Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs. @@ -151,7 +182,7 @@ not (http.request.uri.path eq "/api/error-reporting") **Exemptions:** - `/api/csp-report` is exempted because browser-generated CSP violation reports (via the Reporting API) may not include an `Origin` header. -- `/api/error-reporting` is exempted for consistency with the CSP tunnel — while the Sentry SDK does include `Origin` in its `fetch()` calls, the exemption keeps both tunnel endpoints treated identically. Both endpoints are low-risk (error reporting only, no sensitive data returned) and have their own validation (DSN check, payload format check). +- `/api/error-reporting` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. The exemption exists because the WAF expression cannot selectively allow absent-Origin for CSP while also blocking it for Sentry — the Worker handles both policies independently. **Notes:** - This uses **1 of the 5 free WAF custom rules** available on all plans. diff --git a/src/worker/index.ts b/src/worker/index.ts index cf1aac3a..a4d2d4da 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,7 +1,7 @@ import { CryptoEnv, deriveKey, sealToken, SEAL_SALT } from "./crypto"; import { SessionEnv, ensureSession } from "./session"; import { TurnstileEnv, verifyTurnstile, extractTurnstileToken } from "./turnstile"; -import { validateProxyRequest } from "./validation"; +import { validateProxyRequest, validateOrigin } from "./validation"; interface RateLimiter { limit(options: { key: string }): Promise<{ success: boolean }>; @@ -77,36 +77,49 @@ const SECURITY_HEADERS: Record = { "X-Frame-Options": "DENY", }; -// Simple in-memory rate limiter for token exchange endpoint. +// Simple in-memory rate limiter factory. // Not durable across isolate restarts, but catches burst abuse. // Note: CF-Connecting-IP is set by Cloudflare's proxy layer; if the workers.dev // route is enabled, an attacker could spoof this header. Disable the workers.dev // route in the Cloudflare dashboard for production use. -const TOKEN_RATE_LIMIT = 10; // max requests per window -const TOKEN_RATE_WINDOW_MS = 60_000; // 1 minute -const _tokenRateMap = new Map(); - -function checkTokenRateLimit(ip: string): boolean { - const now = Date.now(); - const entry = _tokenRateMap.get(ip); - if (!entry || now >= entry.resetAt) { - _tokenRateMap.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS }); - return true; - } - entry.count++; - if (entry.count > TOKEN_RATE_LIMIT) return false; - return true; +const PRUNE_THRESHOLD = 100; + +function createIpRateLimiter(limit: number, windowMs: number): { check(ip: string): boolean } { + const map = new Map(); + return { + check(ip: string): boolean { + const now = Date.now(); + const entry = map.get(ip); + if (!entry || now >= entry.resetAt) { + map.set(ip, { count: 1, resetAt: now + windowMs }); + return true; + } + entry.count++; + if (entry.count > limit) return false; + // Periodic cleanup to prevent unbounded map growth. + if (map.size >= PRUNE_THRESHOLD) { + for (const [k, e] of map) { + if (now >= e.resetAt) map.delete(k); + } + } + return true; + }, + }; } -// Periodic cleanup to prevent unbounded map growth. -// Only runs when the map exceeds a threshold to avoid O(N) scan on every request. -const PRUNE_THRESHOLD = 100; -function pruneTokenRateMap(): void { - if (_tokenRateMap.size < PRUNE_THRESHOLD) return; - const now = Date.now(); - for (const [ip, entry] of _tokenRateMap) { - if (now >= entry.resetAt) _tokenRateMap.delete(ip); - } +const tokenRateLimiter = createIpRateLimiter(10, 60_000); // token exchange: 10/min +const sentryRateLimiter = createIpRateLimiter(15, 60_000); // sentry tunnel: 15/min +const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/min +const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding + +// Content-Length pre-check helper — optimization only, not a security boundary. +// Absent or unparseable Content-Length passes through (post-read check is authoritative). +function checkContentLength(request: Request, maxBytes: number): boolean { + const cl = request.headers.get("Content-Length"); + if (cl === null) return true; + const parsed = Number(cl); + if (!Number.isInteger(parsed) || parsed < 0) return true; + return parsed <= maxBytes; } // CORS: strict equality only (SDR-004) @@ -145,16 +158,14 @@ function getProxyCorsHeaders( function isProxyPath(pathname: string): boolean { return ( pathname.startsWith("/api/proxy/") || - pathname.startsWith("/api/jira/") || - pathname.startsWith("/api/gitlab/") + pathname.startsWith("/api/jira/") ); } // ── Validation gate for proxy routes ───────────────────────────────────────── // Returns a Response if rejected, null if validation passes. +// Caller must ensure pathname is a proxy path before calling. function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string): Response | null { - if (!isProxyPath(pathname)) return null; - const origin = request.headers.get("Origin"); // Handle OPTIONS preflight for proxy routes explicitly. @@ -182,7 +193,7 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string } // ── Sealed-token endpoint ──────────────────────────────────────────────────── -const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token", "gitlab-pat"]); +const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token"]); // Module-level cache for derived seal keys, keyed by ":". // SEAL_KEY is a deployment constant — safe to cache per-isolate (follows _sessionKeyCache pattern). @@ -318,6 +329,25 @@ async function handleSentryTunnel( return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + if (!sentryRateLimiter.check(ip)) { + log("warn", "sentry_tunnel_rate_limited", {}, request); + return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); + } + + const originResult = validateOrigin(request, env.ALLOWED_ORIGIN); + if (!originResult.ok) { + log("warn", "sentry_tunnel_origin_rejected", { origin: request.headers.get("Origin") }, request); + return new Response(null, { status: 403, headers: SECURITY_HEADERS }); + } + + if (!checkContentLength(request, SENTRY_ENVELOPE_MAX_BYTES)) { + log("warn", "sentry_tunnel_content_length_exceeded", { + content_length: request.headers.get("Content-Length"), + }, request); + return new Response(null, { status: 413, headers: SECURITY_HEADERS }); + } + const allowedDsn = getOrCacheDsn(env); if (!allowedDsn) { log("warn", "sentry_tunnel_not_configured", {}, request); @@ -434,6 +464,25 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + if (!cspRateLimiter.check(ip)) { + log("warn", "csp_report_rate_limited", {}, request); + return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); + } + + const origin = request.headers.get("Origin"); + if (origin !== null && origin !== env.ALLOWED_ORIGIN) { + log("warn", "csp_report_origin_rejected", { origin }, request); + return new Response(null, { status: 403, headers: SECURITY_HEADERS }); + } + + if (!checkContentLength(request, CSP_REPORT_MAX_BYTES)) { + log("warn", "csp_report_content_length_exceeded", { + content_length: request.headers.get("Content-Length"), + }, request); + return new Response(null, { status: 413, headers: SECURITY_HEADERS }); + } + const allowedDsn = getOrCacheDsn(env); if (!allowedDsn) { return new Response(null, { status: 404, headers: SECURITY_HEADERS }); @@ -509,7 +558,7 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 204, headers: SECURITY_HEADERS }); } -// GitHub OAuth code format validation (SDR-005): alphanumeric, 1-40 chars. +// GitHub OAuth code format validation (SDR-005): alphanumeric, hyphens, underscores, 1-40 chars. // GitHub's code format is undocumented and has changed historically — validate // loosely here; GitHub's server validates the actual code. const VALID_CODE_RE = /^[a-zA-Z0-9_-]{1,40}$/; @@ -524,14 +573,14 @@ async function handleTokenExchange( return errorResponse("method_not_allowed", 405, cors); } - pruneTokenRateMap(); const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; - if (!checkTokenRateLimit(ip)) { + if (!tokenRateLimiter.check(ip)) { log("warn", "token_exchange_rate_limited", {}, request); return new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, headers: { "Content-Type": "application/json", + "Retry-After": "60", ...cors, ...SECURITY_HEADERS, }, @@ -704,12 +753,23 @@ export default { } // ── Proxy routes: validation, session, and rate limiting ───────────────── - // Applies to /api/proxy/*, /api/jira/*, /api/gitlab/* + // Applies to /api/proxy/*, /api/jira/* // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. - const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname); - if (guardResponse !== null) return guardResponse; - + // Proxy routes assume SPA fetch() callers — browser navigation GETs do not send Origin. if (isProxyPath(url.pathname)) { + const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname); + if (guardResponse !== null) return guardResponse; + + // Step 2.5: IP pre-gate — rejects burst abuse before any crypto work (HKDF, HMAC) + const proxyIp = request.headers.get("CF-Connecting-IP") ?? "unknown"; + if (!proxyPreGateLimiter.check(proxyIp)) { + log("warn", "proxy_ip_rate_limited", { pathname: url.pathname }, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "60", ...SECURITY_HEADERS }, + }); + } + // Step 3: Session middleware — ensureSession never throws (SDR-003) const { sessionId, setCookie } = await ensureSession(request, env); @@ -727,14 +787,13 @@ export default { } if (rateLimited) { log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); - const rateLimitResponse = errorResponse("rate_limited", 429); - const headers = new Headers(rateLimitResponse.headers); - headers.set("Retry-After", "60"); - if (setCookie) headers.set("Set-Cookie", setCookie); - return new Response(rateLimitResponse.body, { - status: 429, - headers, - }); + const headers: Record = { + "Content-Type": "application/json", + "Retry-After": "60", + ...SECURITY_HEADERS, + }; + if (setCookie) headers["Set-Cookie"] = setCookie; + return new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, headers }); } // Step 5: Sealed-token endpoint diff --git a/src/worker/validation.ts b/src/worker/validation.ts index bdfad6e9..dd54ed3c 100644 --- a/src/worker/validation.ts +++ b/src/worker/validation.ts @@ -3,6 +3,10 @@ export type ValidationResult = { ok: true } | { ok: false; code: string; status: /** * Validates that the request Origin header matches the allowed origin exactly. * Strict equality only — prevents substring spoofing (e.g. evil.gh.gordoncode.dev). + * + * Returns { ok: false } when Origin header is absent or does not match allowedOrigin. + * The Sentry tunnel relies on this strict behavior to reject requests without an Origin. + * For soft origin checks (allowing absent Origin), use an inline check instead. */ export function validateOrigin(request: Request, allowedOrigin: string): ValidationResult { const origin = request.headers.get("Origin"); diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index 3f35153d..9817f6d9 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -159,7 +159,7 @@ describe("sealToken / unsealToken", () => { describe("sealToken cross-purpose isolation", () => { it("cannot unseal a token sealed with a different purpose (F-003)", async () => { const sealKey = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key:jira-api-token", "encrypt"); - const unsealKey = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key:gitlab-pat", "encrypt"); + const unsealKey = await deriveKey(KEY_A, "sealed-token-v1", "aes-gcm-key:other-purpose", "encrypt"); const sealed = await sealToken("secret-token", sealKey); expect(await unsealToken(sealed, unsealKey)).toBeNull(); }); diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 5847df87..9fb4275b 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; +const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; + function makeEnv(overrides: Partial = {}): Env { return { ASSETS: { fetch: async () => new Response("asset") }, GITHUB_CLIENT_ID: "test_client_id", GITHUB_CLIENT_SECRET: "test_client_secret", - ALLOWED_ORIGIN: "https://gh.gordoncode.dev", + ALLOWED_ORIGIN, SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==", SEAL_KEY: "dGVzdC1zZWFsLWtleQ==", @@ -16,14 +18,25 @@ function makeEnv(overrides: Partial = {}): Env { }; } +let _requestCounter = 0; + function makeCspRequest( body: string, contentType = "application/csp-report", method = "POST", + options: { origin?: string | null } = {}, ): Request { + const headers: Record = { + "Content-Type": contentType, + // Unique IP per request to avoid hitting the in-memory rate limiter across tests + "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, + }; + if (options.origin !== undefined && options.origin !== null) { + headers["Origin"] = options.origin; + } return new Request("https://gh.gordoncode.dev/api/csp-report", { method, - headers: { "Content-Type": contentType }, + headers, body: method !== "GET" ? body : undefined, }); } @@ -31,13 +44,14 @@ function makeCspRequest( describe("Worker CSP report endpoint", () => { let originalFetch: typeof globalThis.fetch; let mockFetch: ReturnType; + let warnSpy: ReturnType; beforeEach(() => { originalFetch = globalThis.fetch; mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); globalThis.fetch = mockFetch as typeof globalThis.fetch; vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -47,7 +61,10 @@ describe("Worker CSP report endpoint", () => { }); it("rejects non-POST requests", async () => { - const req = new Request("https://gh.gordoncode.dev/api/csp-report", { method: "GET" }); + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "GET", + headers: { "CF-Connecting-IP": `10.3.0.${++_requestCounter}` }, + }); const resp = await worker.fetch(req, makeEnv()); expect(resp.status).toBe(405); }); @@ -310,4 +327,112 @@ describe("Worker CSP report endpoint", () => { expect(report["original-policy"]).toBe("font-src 'self'"); expect(report["status-code"]).toBe(200); }); + + // ── Soft origin check ───────────────────────────────────────────────────── + + it("rejects requests with wrong Origin with 403", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + const req = makeCspRequest(body, "application/csp-report", "POST", { origin: "https://evil.example.com" }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(403); + }); + + it("allows requests with missing Origin (soft check — browser CSP reports may lack it)", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + // No origin option means no Origin header in makeCspRequest + const req = makeCspRequest(body, "application/csp-report", "POST"); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(204); + }); + + it("allows requests with correct Origin", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + const req = makeCspRequest(body, "application/csp-report", "POST", { origin: ALLOWED_ORIGIN }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(204); + }); + + // ── Rate limiting ───────────────────────────────────────────────────────── + + it("rate limits after 15 requests from same IP", async () => { + const env = makeEnv(); + const fixedIp = "10.3.99.1"; + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + for (let i = 0; i < 15; i++) { + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + body, + }); + const resp = await worker.fetch(req, env); + expect(resp.status).not.toBe(429); + } + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + body, + }); + const resp = await worker.fetch(req, env); + expect(resp.status).toBe(429); + expect(resp.headers.get("Retry-After")).toBe("60"); + }); + + it("rate limits are per-IP — different IPs have independent counters", async () => { + const env = makeEnv(); + const fixedIp = "10.3.99.2"; + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + // Exhaust limit for fixedIp + for (let i = 0; i < 15; i++) { + await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + body, + }), env); + } + const limited = await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + body, + }), env); + expect(limited.status).toBe(429); + + // Different IP should still succeed + const otherResp = await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": "10.3.99.3" }, + body, + }), env); + expect(otherResp.status).toBe(204); + }); + + // ── Content-Length pre-check ────────────────────────────────────────────── + + it("rejects Content-Length exceeding 64KB with 413 and logs csp_report_content_length_exceeded", async () => { + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { + "Content-Type": "application/csp-report", + "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, + "Content-Length": String(64 * 1024 + 1), + }, + body: "x", + }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(413); + + // TCG-002: verify the structured log event fires + const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => { + try { return JSON.parse(c[0] as string) as Record; } catch { return null; } + }).filter(Boolean) as Array>; + const sizeLog = warnCalls.find((l) => typeof l["event"] === "string" && (l["event"] as string).includes("csp_report_content_length_exceeded")); + expect(sizeLog).toBeDefined(); + }); + + it("allows requests without Content-Length header", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + const req = makeCspRequest(body); + expect(req.headers.get("Content-Length")).toBeNull(); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(204); + }); }); diff --git a/tests/worker/helpers.ts b/tests/worker/helpers.ts new file mode 100644 index 00000000..e644efb5 --- /dev/null +++ b/tests/worker/helpers.ts @@ -0,0 +1,28 @@ +import { vi } from "vitest"; + +/** Parse all structured log calls from a console spy, returning {level, entry} tuples. */ +export 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. */ +export function findLog( + logs: Array<{ level: string; entry: Record }>, + event: string +): { level: string; entry: Record } | undefined { + return logs.find((l) => l.entry.event === event); +} diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index db03396e..5771677b 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, findLog } from "./helpers"; const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; @@ -28,7 +29,7 @@ function makeRequest( const url = `https://gh.gordoncode.dev${path}`; const headers: Record = { // Unique IP per request to avoid hitting the in-memory rate limiter across tests - "CF-Connecting-IP": `127.0.0.${++_requestCounter}`, + "CF-Connecting-IP": `10.1.0.${++_requestCounter}`, }; if (options.origin !== undefined) { headers["Origin"] = options.origin; @@ -51,32 +52,6 @@ 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; @@ -103,7 +78,7 @@ describe("Worker OAuth endpoint", () => { // ── Rate limiting ──────────────────────────────────────────────────────── it("returns 429 after exceeding 10 requests per minute from the same IP", async () => { - const fixedIp = "10.0.0.99"; + const fixedIp = "10.1.99.1"; function makeRateLimitRequest() { return new Request("https://gh.gordoncode.dev/api/oauth/token", { method: "POST", @@ -135,12 +110,13 @@ describe("Worker OAuth endpoint", () => { expect(resp.status).toBe(429); const body = await resp.json() as { error: string }; expect(body.error).toBe("rate_limited"); + expect(resp.headers.get("Retry-After")).toBe("60"); // Should include security headers expect(resp.headers.get("X-Content-Type-Options")).toBe("nosniff"); }); it("allows requests again after the rate-limit window expires", async () => { - const fixedIp = "10.0.0.100"; + const fixedIp = "10.1.99.2"; function makeRateLimitRequest() { return new Request("https://gh.gordoncode.dev/api/oauth/token", { method: "POST", @@ -849,228 +825,4 @@ describe("Worker OAuth endpoint", () => { }); }); - // ── 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 empty string", async () => { - const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); - const res = await worker.fetch(req, makeEnv({ SENTRY_DSN: "" })); - expect(res.status).toBe(404); - }); - - it("returns 404 when SENTRY_DSN is undefined", async () => { - const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); - const res = await worker.fetch(req, makeEnv({ SENTRY_DSN: undefined as unknown as string })); - 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/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 3fb16e8c..24280858 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -9,6 +9,8 @@ const TEST_SESSION_KEY = "dGVzdC1zZXNzaW9uLWtleQ=="; // "test-seal-key" base64-encoded const TEST_SEAL_KEY = "dGVzdC1zZWFsLWtleQ=="; +let _requestCounter = 0; + function makeEnv(overrides: Partial = {}): Env { return { ASSETS: { fetch: async () => new Response("asset") }, @@ -40,7 +42,10 @@ function makeSealRequest(options: { method = "POST", } = options; - const headers: Record = {}; + const headers: Record = { + // Unique IP per request to avoid hitting the in-memory IP pre-gate rate limiter across tests + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, + }; if (origin) headers["Origin"] = origin; if (addXRequestedWith) headers["X-Requested-With"] = "fetch"; if (addContentType) headers["Content-Type"] = "application/json"; @@ -374,23 +379,6 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("not_found"); }); - it("valid POST to /api/gitlab/token falls through to 404 with not_found", async () => { - const req = new Request("https://gh.gordoncode.dev/api/gitlab/token", { - method: "POST", - headers: { - "Origin": ALLOWED_ORIGIN, - "X-Requested-With": "fetch", - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }); - const res = await worker.fetch(req, makeEnv()); - - expect(res.status).toBe(404); - const json = await res.json() as Record; - expect(json["error"]).toBe("not_found"); - }); - // ── Session cookie issuance ─────────────────────────────────────────────── it("first request issues a session cookie in Set-Cookie", async () => { @@ -471,4 +459,104 @@ describe("Worker /api/proxy/seal endpoint", () => { const allLogText = allLogs.map((l) => JSON.stringify(l)).join("\n"); expect(allLogText).not.toContain("ghp_abc123"); }); + + // ── Proxy IP pre-gate ───────────────────────────────────────────────────── + + describe("proxy IP pre-gate", () => { + it("rejects proxy requests after IP threshold exceeded", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const env = makeEnv(); + const fixedIp = "10.4.99.1"; + // Send 60 requests — all should pass + for (let i = 0; i < 60; i++) { + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ token: "ghp_test", purpose: "jira-api-token" }), + }); + const res = await worker.fetch(req, env); + expect(res.status).not.toBe(429); + } + // 61st request from same IP should be rejected + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ token: "ghp_test", purpose: "jira-api-token" }), + }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + // TCG-003: pre-gate 429 response body must contain {error: "rate_limited"} + const body = await res.json() as Record; + expect(body["error"]).toBe("rate_limited"); + }); + + it("does not issue session cookie when IP pre-gate rejects", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const env = makeEnv(); + const fixedIp = "10.4.99.4"; + // Exhaust the 60/min limit + for (let i = 0; i < 60; i++) { + await worker.fetch(new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ token: "ghp_test", purpose: "jira-api-token" }), + }), env); + } + // 61st — should be rejected with no Set-Cookie (ensureSession was never called) + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ token: "ghp_test", purpose: "jira-api-token" }), + }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(429); + expect(res.headers.get("Set-Cookie")).toBeNull(); + }); + + it("IP pre-gate is independent of session-based rate limiter", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + // A request that passes the IP pre-gate should still go through session + CF rate limiter as normal + const env = makeEnv(); + const req = makeSealRequest(); + const res = await worker.fetch(req, env); + // Should succeed — IP pre-gate passed, session created, CF limiter allowed + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + }); + }); }); diff --git a/tests/worker/sentry-tunnel.test.ts b/tests/worker/sentry-tunnel.test.ts new file mode 100644 index 00000000..1b50faee --- /dev/null +++ b/tests/worker/sentry-tunnel.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, findLog } from "./helpers"; + +const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +const SENTRY_HOST = "o123456.ingest.sentry.io"; +const SENTRY_PROJECT_ID = "7890123"; +const VALID_DSN = `https://abc123@${SENTRY_HOST}/${SENTRY_PROJECT_ID}`; + +function makeEnv(overrides: Partial = {}): Env { + return { + ASSETS: { fetch: async () => new Response("asset") }, + GITHUB_CLIENT_ID: "test_client_id", + GITHUB_CLIENT_SECRET: "test_client_secret", + ALLOWED_ORIGIN, + SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", + SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==", + SEAL_KEY: "dGVzdC1zZWFsLWtleQ==", + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, + ...overrides, + }; +} + +let _requestCounter = 0; + +function makeEnvelope(dsn: string, eventPayload = "{}"): string { + return `${JSON.stringify({ dsn })}\n${JSON.stringify({ type: "event" })}\n${eventPayload}`; +} + +function makeTunnelRequest(body: string, options: { origin?: string | null; ip?: string } = {}): Request { + const ip = options.ip ?? `10.2.0.${++_requestCounter}`; + const headers: Record = { + "Content-Type": "application/x-sentry-envelope", + "CF-Connecting-IP": ip, + }; + if (options.origin !== null) { + headers["Origin"] = options.origin ?? ALLOWED_ORIGIN; + } + return new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "POST", + headers, + body, + }); +} + + +describe("Sentry tunnel (/api/error-reporting)", () => { + 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(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // ── Migrated tests from oauth.test.ts ───────────────────────────────────── + + 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", + headers: { "CF-Connecting-IP": `10.2.0.${++_requestCounter}` }, + }); + 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 empty string", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + const res = await worker.fetch(req, makeEnv({ SENTRY_DSN: "" })); + expect(res.status).toBe(404); + }); + + it("returns 404 when SENTRY_DSN is undefined", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + const res = await worker.fetch(req, makeEnv({ SENTRY_DSN: undefined as unknown as string })); + 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", + headers: { "CF-Connecting-IP": `10.2.0.${++_requestCounter}` }, + }); + 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", + headers: { "CF-Connecting-IP": `10.2.0.${++_requestCounter}` }, + }); + 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); + }); + + // ── New guard tests ─────────────────────────────────────────────────────── + + it("rejects requests with wrong Origin with 403 and logs both warnings", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: "https://evil.example.com" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + + const logs = collectLogs(consoleSpy); + // cors_origin_mismatch fires at top-level routing (before handler dispatch) + 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"); + // sentry_tunnel_origin_rejected fires at handler level + const originLog = findLog(logs, "sentry_tunnel_origin_rejected"); + expect(originLog).toBeDefined(); + expect(originLog!.level).toBe("warn"); + expect(originLog!.entry.origin).toBe("https://evil.example.com"); + }); + + it("rejects requests with missing Origin with 403 (strict — SPA always sends it)", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: null }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + + const logs = collectLogs(consoleSpy); + const originLog = findLog(logs, "sentry_tunnel_origin_rejected"); + expect(originLog).toBeDefined(); + }); + + it("allows requests with correct Origin", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: ALLOWED_ORIGIN }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + it("rate limits after 15 requests from same IP", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const fixedIp = "10.2.99.1"; + const env = makeEnv(); + for (let i = 0; i < 15; i++) { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: fixedIp }); + const res = await worker.fetch(req, env); + expect(res.status).not.toBe(429); + } + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: fixedIp }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + }); + + it("rate limits are per-IP — different IPs have independent counters", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const env = makeEnv(); + const fixedIp = "10.2.99.2"; + // Exhaust the limit for fixedIp + for (let i = 0; i < 15; i++) { + await worker.fetch(makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: fixedIp }), env); + } + const limited = await worker.fetch(makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: fixedIp }), env); + expect(limited.status).toBe(429); + + // Different IP should still succeed + const otherIp = "10.2.99.3"; + const otherRes = await worker.fetch(makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: otherIp }), env); + expect(otherRes.status).toBe(200); + }); + + it("Sentry rate limiter is independent of CSP rate limiter", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const env = makeEnv(); + const sharedIp = "10.99.0.1"; + // Exhaust Sentry rate limiter for sharedIp + for (let i = 0; i < 15; i++) { + await worker.fetch(makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: sharedIp }), env); + } + const sentryLimited = await worker.fetch(makeTunnelRequest(makeEnvelope(VALID_DSN), { ip: sharedIp }), env); + expect(sentryLimited.status).toBe(429); + + // CSP request from same IP should NOT be rate limited by Sentry's limiter + const cspReq = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { + "Content-Type": "application/csp-report", + "CF-Connecting-IP": sharedIp, + }, + body: JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }), + }); + const cspRes = await worker.fetch(cspReq, env); + // Should not be 429 due to Sentry rate limit (may be other status, just not sentry-rate-limited) + expect(cspRes.status).not.toBe(429); + }); + + it("rejects Content-Length exceeding 256KB with 413 before reading body", async () => { + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "POST", + headers: { + "Content-Type": "application/x-sentry-envelope", + "CF-Connecting-IP": `10.2.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, + "Content-Length": String(256 * 1024 + 1), + }, + body: "x", + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(413); + + const logs = collectLogs(consoleSpy); + const log = findLog(logs, "sentry_tunnel_content_length_exceeded"); + expect(log).toBeDefined(); + expect(log!.level).toBe("warn"); + }); + + it("allows requests without Content-Length header (chunked transfer)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + // makeTunnelRequest does not set Content-Length + expect(req.headers.get("Content-Length")).toBeNull(); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + // TCG-001: Content-Length edge cases — non-numeric and negative values pass through + it("allows requests with non-numeric Content-Length (passes through to post-read check)", async () => { + // "100abc" → Number("100abc") = NaN, !Number.isInteger(NaN) is true → checkContentLength returns true + 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", + "CF-Connecting-IP": `10.2.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, + "Content-Length": "100abc", + }, + body: makeEnvelope(VALID_DSN), + }); + const res = await worker.fetch(req, makeEnv()); + // Must not be rejected by the Content-Length pre-check (413) — post-read check is authoritative + expect(res.status).not.toBe(413); + }); + + it("allows requests with negative Content-Length (passes through to post-read check)", async () => { + // "-1" → Number("-1") = -1, parsed < 0 is true → checkContentLength returns true + 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", + "CF-Connecting-IP": `10.2.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, + "Content-Length": "-1", + }, + body: makeEnvelope(VALID_DSN), + }); + const res = await worker.fetch(req, makeEnv()); + // Must not be rejected by the Content-Length pre-check (413) — post-read check is authoritative + expect(res.status).not.toBe(413); + }); + + // CR-006: Missing-Origin takes a different code path — cors_origin_mismatch should NOT fire + it("rejects requests with missing Origin: logs sentry_tunnel_origin_rejected but NOT cors_origin_mismatch", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: null }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + + const logs = collectLogs(consoleSpy); + // Handler-level rejection must be logged + const originLog = findLog(logs, "sentry_tunnel_origin_rejected"); + expect(originLog).toBeDefined(); + // Top-level CORS check only fires when origin is non-null and doesn't match — + // absent Origin skips it, so cors_origin_mismatch must NOT appear here + const corsLog = findLog(logs, "cors_origin_mismatch"); + expect(corsLog).toBeUndefined(); + }); +}); From 7564caa6bc784c3ed052f5bcfb89dc0d65db6a74 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 21:16:37 -0400 Subject: [PATCH 19/56] fix(worker): uses shared test helpers and fixes comment --- src/worker/index.ts | 2 +- tests/worker/csp-report.test.ts | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index a4d2d4da..37af7c7a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -113,7 +113,7 @@ const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/m const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding // Content-Length pre-check helper — optimization only, not a security boundary. -// Absent or unparseable Content-Length passes through (post-read check is authoritative). +// Absent, non-integer, or negative Content-Length passes through (post-read check is authoritative). function checkContentLength(request: Request, maxBytes: number): boolean { const cl = request.headers.get("Content-Length"); if (cl === null) return true; diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 9fb4275b..fcfca635 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, findLog } from "./helpers"; const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; @@ -44,15 +45,21 @@ function makeCspRequest( describe("Worker CSP report endpoint", () => { let originalFetch: typeof globalThis.fetch; let mockFetch: ReturnType; - let warnSpy: ReturnType; + let consoleSpy: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; beforeEach(() => { originalFetch = globalThis.fetch; mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); globalThis.fetch = mockFetch as typeof globalThis.fetch; - vi.spyOn(console, "info").mockImplementation(() => {}); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); + consoleSpy = { + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; }); afterEach(() => { @@ -421,10 +428,8 @@ describe("Worker CSP report endpoint", () => { expect(resp.status).toBe(413); // TCG-002: verify the structured log event fires - const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => { - try { return JSON.parse(c[0] as string) as Record; } catch { return null; } - }).filter(Boolean) as Array>; - const sizeLog = warnCalls.find((l) => typeof l["event"] === "string" && (l["event"] as string).includes("csp_report_content_length_exceeded")); + const logs = collectLogs(consoleSpy); + const sizeLog = findLog(logs, "csp_report_content_length_exceeded"); expect(sizeLog).toBeDefined(); }); From fa63140e813b56c8fbb598b71d5b7650edd77584 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 12 Apr 2026 21:53:44 -0400 Subject: [PATCH 20/56] fix(worker): moves prune before rate limit check, uses shared helpers --- src/worker/index.ts | 5 +++-- tests/worker/seal.test.ts | 20 ++++++-------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 37af7c7a..3aa1154c 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -95,13 +95,14 @@ function createIpRateLimiter(limit: number, windowMs: number): { check(ip: strin return true; } entry.count++; - if (entry.count > limit) return false; - // Periodic cleanup to prevent unbounded map growth. + // Periodic cleanup — runs on both allowed and denied paths to prevent + // unbounded map growth during distributed attacks where all IPs are over-limit. if (map.size >= PRUNE_THRESHOLD) { for (const [k, e] of map) { if (now >= e.resetAt) map.delete(k); } } + if (entry.count > limit) return false; return true; }, }; diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 24280858..6be04fd3 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, findLog } from "./helpers"; const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; @@ -441,22 +442,13 @@ describe("Worker /api/proxy/seal endpoint", () => { const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "jira-api-token" } }); await worker.fetch(req, makeEnv()); - const allLogs: Array> = []; - for (const [, spy] of Object.entries(consoleSpies)) { - for (const call of spy.mock.calls) { - try { - allLogs.push(JSON.parse(call[0] as string) as Record); - } catch { - // ignore non-JSON - } - } - } - const sealLog = allLogs.find((l) => l["event"] === "token_sealed"); + const allLogs = collectLogs(consoleSpies); + const sealLog = findLog(allLogs, "token_sealed"); expect(sealLog).toBeDefined(); - expect(sealLog!["purpose"]).toBe("jira-api-token"); - expect(sealLog!["token_length"]).toBe(10); // "ghp_abc123".length + expect(sealLog!.entry["purpose"]).toBe("jira-api-token"); + expect(sealLog!.entry["token_length"]).toBe(10); // "ghp_abc123".length // Must NOT log the actual token value - const allLogText = allLogs.map((l) => JSON.stringify(l)).join("\n"); + const allLogText = allLogs.map((l) => JSON.stringify(l.entry)).join("\n"); expect(allLogText).not.toContain("ghp_abc123"); }); From e5813d5ee9c1e1ff057dd3b854d8124cca347fbf Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 07:50:09 -0400 Subject: [PATCH 21/56] fix(worker): adds Origin null tests, standardizes consoleSpy naming --- tests/worker/csp-report.test.ts | 7 +++++++ tests/worker/seal.test.ts | 6 +++--- tests/worker/sentry-tunnel.test.ts | 6 ++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index fcfca635..6f05e983 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -352,6 +352,13 @@ describe("Worker CSP report endpoint", () => { expect(resp.status).toBe(204); }); + it("rejects requests with Origin: null (string literal from sandboxed iframes) with 403", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + const req = makeCspRequest(body, "application/csp-report", "POST", { origin: "null" }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(403); + }); + it("allows requests with correct Origin", async () => { const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); const req = makeCspRequest(body, "application/csp-report", "POST", { origin: ALLOWED_ORIGIN }); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 6be04fd3..258ea1de 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -62,11 +62,11 @@ function makeSealRequest(options: { describe("Worker /api/proxy/seal endpoint", () => { let originalFetch: typeof globalThis.fetch; - let consoleSpies: { info: ReturnType; warn: ReturnType; error: ReturnType }; + let consoleSpy: { info: ReturnType; warn: ReturnType; error: ReturnType }; beforeEach(() => { originalFetch = globalThis.fetch; - consoleSpies = { + consoleSpy = { info: vi.spyOn(console, "info").mockImplementation(() => {}), warn: vi.spyOn(console, "warn").mockImplementation(() => {}), error: vi.spyOn(console, "error").mockImplementation(() => {}), @@ -442,7 +442,7 @@ describe("Worker /api/proxy/seal endpoint", () => { const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "jira-api-token" } }); await worker.fetch(req, makeEnv()); - const allLogs = collectLogs(consoleSpies); + const allLogs = collectLogs(consoleSpy); const sealLog = findLog(allLogs, "token_sealed"); expect(sealLog).toBeDefined(); expect(sealLog!.entry["purpose"]).toBe("jira-api-token"); diff --git a/tests/worker/sentry-tunnel.test.ts b/tests/worker/sentry-tunnel.test.ts index 1b50faee..76cdf8ff 100644 --- a/tests/worker/sentry-tunnel.test.ts +++ b/tests/worker/sentry-tunnel.test.ts @@ -293,6 +293,12 @@ describe("Sentry tunnel (/api/error-reporting)", () => { expect(originLog).toBeDefined(); }); + it("rejects requests with Origin: null (string literal from sandboxed iframes) with 403", async () => { + const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: "null" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + }); + it("allows requests with correct Origin", async () => { globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); const req = makeTunnelRequest(makeEnvelope(VALID_DSN), { origin: ALLOWED_ORIGIN }); From 2bc09578bf1525fac0d9086187bd835ab991c715 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 08:23:02 -0400 Subject: [PATCH 22/56] feat(worker): hardens IP, key cache, CSP scrub, token rate limit - Adds getClientIp helper with missing-header warning for misconfiguration - Removes raw SEAL_KEY from cache key (purpose + fingerprint rotation) - Adds CSP field sanitization: control chars stripped, 2048-char cap - Adds durable rate limiting to token exchange via PROXY_RATE_LIMITER - Adds consecutive failure tracking for durable rate limiter - Standardizes consoleSpy naming across test files --- src/worker/index.ts | 94 ++++++++++++++++++++++++++++----- tests/worker/csp-report.test.ts | 34 ++++++++++++ tests/worker/oauth.test.ts | 25 +++++++++ 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 3aa1154c..586b34de 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -113,6 +113,26 @@ const sentryRateLimiter = createIpRateLimiter(15, 60_000); // sentry tunnel: 1 const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/min const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding +// Extract client IP from CF-Connecting-IP header. +// In production (behind Cloudflare proxy), this header is always present and trustworthy. +// In local dev (wrangler dev), the header is absent — falls back to "unknown" so dev +// works without a real IP. Logs a warning on first miss to flag production misconfiguration. +let _ipMissingWarned = false; +function getClientIp(request: Request): string { + const ip = request.headers.get("CF-Connecting-IP"); + if (ip) return ip; + if (!_ipMissingWarned) { + _ipMissingWarned = true; + log("warn", "cf_connecting_ip_missing", {}, request); + } + return "unknown"; +} + +// Consecutive failure counter for durable rate limiter — escalates log severity +// when the CF binding is persistently misconfigured or unavailable. +let _durableRateLimiterFailures = 0; +const DURABLE_FAILURE_ESCALATION_THRESHOLD = 3; + // Content-Length pre-check helper — optimization only, not a security boundary. // Absent, non-integer, or negative Content-Length passes through (post-read check is authoritative). function checkContentLength(request: Request, maxBytes: number): boolean { @@ -197,8 +217,11 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token"]); // Module-level cache for derived seal keys, keyed by ":". -// SEAL_KEY is a deployment constant — safe to cache per-isolate (follows _sessionKeyCache pattern). +// Seal key cache: keyed by purpose, invalidated when SEAL_KEY changes. +// Tracks a fingerprint of the SEAL_KEY (first 8 chars) to detect rotation +// without storing raw key material as a Map key. const _sealKeyCache = new Map(); +let _sealKeyFingerprint = ""; async function handleProxySeal(request: Request, env: Env, sessionId: string): Promise { if (request.method !== "POST") { @@ -255,11 +278,17 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P let sealed: string; try { // SC-8: derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) - const cacheKey = env.SEAL_KEY + ":" + purpose; - let key = _sealKeyCache.get(cacheKey); + // Keyed by purpose only — avoids storing raw key material as a Map key string. + // Fingerprint detects SEAL_KEY rotation without retaining the full secret. + const fingerprint = env.SEAL_KEY.slice(0, 8); + if (fingerprint !== _sealKeyFingerprint) { + _sealKeyCache.clear(); + _sealKeyFingerprint = fingerprint; + } + let key = _sealKeyCache.get(purpose); if (key === undefined) { key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt"); - _sealKeyCache.set(cacheKey, key); + _sealKeyCache.set(purpose, key); } sealed = await sealToken(token, key); } catch (err) { @@ -330,7 +359,7 @@ async function handleSentryTunnel( return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } - const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const ip = getClientIp(request); if (!sentryRateLimiter.check(ip)) { log("warn", "sentry_tunnel_rate_limited", {}, request); return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); @@ -447,9 +476,23 @@ function scrubReportUrl(url: unknown): string | undefined { return url.replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]"); } +const CSP_FIELD_MAX_LENGTH = 2048; +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g; + +function sanitizeCspField(value: unknown): unknown { + if (typeof value !== "string") return value; + // Strip control characters and cap length to prevent log/SIEM injection via Sentry + return value.replace(CONTROL_CHARS_RE, "").slice(0, CSP_FIELD_MAX_LENGTH); +} + function scrubCspReportBody(body: Record): Record { const scrubbed = { ...body }; - // Legacy report-uri format uses kebab-case keys + // Sanitize all string fields — attacker-controlled CSP report bodies are forwarded to Sentry + for (const key of Object.keys(scrubbed)) { + scrubbed[key] = sanitizeCspField(scrubbed[key]); + } + // Legacy report-uri format uses kebab-case keys — scrub OAuth params from URLs for (const key of ["document-uri", "blocked-uri", "source-file", "referrer"]) { if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]); } @@ -465,7 +508,7 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } - const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const ip = getClientIp(request); if (!cspRateLimiter.check(ip)) { log("warn", "csp_report_rate_limited", {}, request); return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); @@ -574,7 +617,7 @@ async function handleTokenExchange( return errorResponse("method_not_allowed", 405, cors); } - const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const ip = getClientIp(request); if (!tokenRateLimiter.check(ip)) { log("warn", "token_exchange_rate_limited", {}, request); return new Response(JSON.stringify({ error: "rate_limited" }), { @@ -588,6 +631,29 @@ async function handleTokenExchange( }); } + // Durable rate limiting — enforces global cross-isolate limit via CF binding. + // Keyed by "token:{ip}" to avoid collision with session-keyed proxy limits. + // Fail open — in-memory limiter already checked, this is defense-in-depth. + try { + const { success } = await env.PROXY_RATE_LIMITER.limit({ key: `token:${ip}` }); + if (!success) { + log("warn", "token_exchange_rate_limited_durable", {}, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + ...cors, + ...SECURITY_HEADERS, + }, + }); + } + } catch (err) { + log("warn", "token_rate_limiter_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + } + log("info", "token_exchange_started", {}, request); const contentType = request.headers.get("Content-Type") ?? ""; @@ -762,7 +828,7 @@ export default { if (guardResponse !== null) return guardResponse; // Step 2.5: IP pre-gate — rejects burst abuse before any crypto work (HKDF, HMAC) - const proxyIp = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const proxyIp = getClientIp(request); if (!proxyPreGateLimiter.check(proxyIp)) { log("warn", "proxy_ip_rate_limited", { pathname: url.pathname }, request); return new Response(JSON.stringify({ error: "rate_limited" }), { @@ -779,12 +845,16 @@ export default { try { const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); rateLimited = !success; + _durableRateLimiterFailures = 0; // reset on success } catch (err) { - log("error", "rate_limiter_failed", { + _durableRateLimiterFailures++; + const severity = _durableRateLimiterFailures >= DURABLE_FAILURE_ESCALATION_THRESHOLD ? "error" : "warn"; + log(severity, "rate_limiter_failed", { error: err instanceof Error ? err.message : "unknown", + consecutive_failures: _durableRateLimiterFailures, }, request); - // Fail open — rate limiter misconfiguration should not block all proxy requests. - // Turnstile and session binding still protect the seal endpoint. + // Fail open — IP pre-gate (60/min) and Turnstile still protect. + // Consecutive failures escalate log severity to surface persistent misconfiguration. } if (rateLimited) { log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 6f05e983..81ee1743 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -335,6 +335,40 @@ describe("Worker CSP report endpoint", () => { expect(report["status-code"]).toBe(200); }); + // ── Field sanitization ──────────────────────────────────────────────────── + + it("strips control characters from CSP report string fields", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/", + "violated-directive": "script-src\x00\x01\x7F injected", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const report = sentryBody["csp-report"]; + expect(report["violated-directive"]).toBe("script-src injected"); + expect(report["violated-directive"]).not.toContain("\x00"); + }); + + it("truncates oversized CSP report string fields to 2048 chars", async () => { + const longValue = "x".repeat(3000); + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/", + "violated-directive": longValue, + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const report = sentryBody["csp-report"]; + expect(report["violated-directive"].length).toBe(2048); + }); + // ── Soft origin check ───────────────────────────────────────────────────── it("rejects requests with wrong Origin with 403", async () => { diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index 5771677b..53c75127 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -158,6 +158,31 @@ describe("Worker OAuth endpoint", () => { } }); + it("rejects token exchange when durable rate limiter returns failure", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_test", token_type: "bearer", scope: "repo" }), { status: 200 }) + ); + const env = makeEnv({ PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: false }) } }); + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("60"); + }); + + it("continues token exchange when durable rate limiter throws (fail-open)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: "ghu_test", token_type: "bearer", scope: "repo" }), { status: 200 }) + ); + const env = makeEnv({ PROXY_RATE_LIMITER: { limit: vi.fn().mockRejectedValue(new Error("binding error")) } }); + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(200); + + const logs = collectLogs(consoleSpy); + const failLog = findLog(logs, "token_rate_limiter_failed"); + expect(failLog).toBeDefined(); + }); + // ── Token exchange ───────────────────────────────────────────────────────── it("POST /api/oauth/token with valid code returns access_token, token_type, scope", async () => { From 9a27ab3054fec5afaa74b9a01e489ed3fb38de86 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 08:32:04 -0400 Subject: [PATCH 23/56] fix(worker): rejects missing IP, validates rate limiter binding exists - getClientIp returns null instead of falling back to 'unknown' - All endpoints reject 400 when CF-Connecting-IP is absent - PROXY_RATE_LIMITER binding validated before use (503 if missing) - Transient .limit() errors still fail open; missing binding fails closed - Tests updated to include CF-Connecting-IP on all inline requests --- src/worker/index.ts | 101 ++++++++++++++++++++----------------- tests/worker/oauth.test.ts | 1 + tests/worker/seal.test.ts | 1 + 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 586b34de..9d835b93 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -29,7 +29,8 @@ type ErrorCode = | "invalid_content_type" | "turnstile_failed" | "rate_limited" - | "seal_failed"; + | "seal_failed" + | "internal_error"; // Structured logging — Cloudflare auto-indexes JSON fields for querying. // NEVER log secrets: codes, tokens, client_secret, cookie values. @@ -113,26 +114,13 @@ const sentryRateLimiter = createIpRateLimiter(15, 60_000); // sentry tunnel: 1 const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/min const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding -// Extract client IP from CF-Connecting-IP header. -// In production (behind Cloudflare proxy), this header is always present and trustworthy. -// In local dev (wrangler dev), the header is absent — falls back to "unknown" so dev -// works without a real IP. Logs a warning on first miss to flag production misconfiguration. -let _ipMissingWarned = false; -function getClientIp(request: Request): string { - const ip = request.headers.get("CF-Connecting-IP"); - if (ip) return ip; - if (!_ipMissingWarned) { - _ipMissingWarned = true; - log("warn", "cf_connecting_ip_missing", {}, request); - } - return "unknown"; +// CF-Connecting-IP is set by Cloudflare's proxy layer in production and by +// miniflare/workerd in local dev. Always present in any real request path. +// Returns null only for malformed/synthetic requests — callers must reject. +function getClientIp(request: Request): string | null { + return request.headers.get("CF-Connecting-IP"); } -// Consecutive failure counter for durable rate limiter — escalates log severity -// when the CF binding is persistently misconfigured or unavailable. -let _durableRateLimiterFailures = 0; -const DURABLE_FAILURE_ESCALATION_THRESHOLD = 3; - // Content-Length pre-check helper — optimization only, not a security boundary. // Absent, non-integer, or negative Content-Length passes through (post-read check is authoritative). function checkContentLength(request: Request, maxBytes: number): boolean { @@ -360,6 +348,9 @@ async function handleSentryTunnel( } const ip = getClientIp(request); + if (!ip) { + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } if (!sentryRateLimiter.check(ip)) { log("warn", "sentry_tunnel_rate_limited", {}, request); return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); @@ -509,6 +500,9 @@ async function handleCspReport(request: Request, env: Env): Promise { } const ip = getClientIp(request); + if (!ip) { + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } if (!cspRateLimiter.check(ip)) { log("warn", "csp_report_rate_limited", {}, request); return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); @@ -618,6 +612,9 @@ async function handleTokenExchange( } const ip = getClientIp(request); + if (!ip) { + return errorResponse("invalid_request", 400, cors); + } if (!tokenRateLimiter.check(ip)) { log("warn", "token_exchange_rate_limited", {}, request); return new Response(JSON.stringify({ error: "rate_limited" }), { @@ -633,25 +630,30 @@ async function handleTokenExchange( // Durable rate limiting — enforces global cross-isolate limit via CF binding. // Keyed by "token:{ip}" to avoid collision with session-keyed proxy limits. - // Fail open — in-memory limiter already checked, this is defense-in-depth. - try { - const { success } = await env.PROXY_RATE_LIMITER.limit({ key: `token:${ip}` }); - if (!success) { - log("warn", "token_exchange_rate_limited_durable", {}, request); - return new Response(JSON.stringify({ error: "rate_limited" }), { - status: 429, - headers: { - "Content-Type": "application/json", - "Retry-After": "60", - ...cors, - ...SECURITY_HEADERS, - }, - }); + // Missing binding = deployment bug → fail closed. Transient error → fail open. + if (typeof env.PROXY_RATE_LIMITER?.limit === "function") { + try { + const { success } = await env.PROXY_RATE_LIMITER.limit({ key: `token:${ip}` }); + if (!success) { + log("warn", "token_exchange_rate_limited_durable", {}, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + ...cors, + ...SECURITY_HEADERS, + }, + }); + } + } catch (err) { + log("error", "token_rate_limiter_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); } - } catch (err) { - log("warn", "token_rate_limiter_failed", { - error: err instanceof Error ? err.message : "unknown", - }, request); + } else { + log("error", "rate_limiter_binding_missing", {}, request); + return errorResponse("internal_error", 503, cors); } log("info", "token_exchange_started", {}, request); @@ -829,6 +831,12 @@ export default { // Step 2.5: IP pre-gate — rejects burst abuse before any crypto work (HKDF, HMAC) const proxyIp = getClientIp(request); + if (!proxyIp) { + return new Response(JSON.stringify({ error: "invalid_request" }), { + status: 400, + headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + }); + } if (!proxyPreGateLimiter.check(proxyIp)) { log("warn", "proxy_ip_rate_limited", { pathname: url.pathname }, request); return new Response(JSON.stringify({ error: "rate_limited" }), { @@ -840,21 +848,24 @@ export default { // Step 3: Session middleware — ensureSession never throws (SDR-003) const { sessionId, setCookie } = await ensureSession(request, env); - // Step 4: Rate limiting using session ID as key + // Step 4: Durable rate limiting using session ID as key. + // Missing binding = deployment bug → fail closed (503). + // Transient .limit() error on existing binding → fail open (IP pre-gate still protects). let rateLimited = false; + if (typeof env.PROXY_RATE_LIMITER?.limit !== "function") { + log("error", "rate_limiter_binding_missing", {}, request); + const r503 = errorResponse("internal_error", 503); + const h503 = new Headers(r503.headers); + if (setCookie) h503.set("Set-Cookie", setCookie); + return new Response(r503.body, { status: 503, headers: h503 }); + } try { const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); rateLimited = !success; - _durableRateLimiterFailures = 0; // reset on success } catch (err) { - _durableRateLimiterFailures++; - const severity = _durableRateLimiterFailures >= DURABLE_FAILURE_ESCALATION_THRESHOLD ? "error" : "warn"; - log(severity, "rate_limiter_failed", { + log("error", "rate_limiter_failed", { error: err instanceof Error ? err.message : "unknown", - consecutive_failures: _durableRateLimiterFailures, }, request); - // Fail open — IP pre-gate (60/min) and Turnstile still protect. - // Consecutive failures escalate log severity to surface persistent misconfiguration. } if (rateLimited) { log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index 53c75127..43c614bb 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -710,6 +710,7 @@ describe("Worker OAuth endpoint", () => { headers: { "Origin": ALLOWED_ORIGIN, "Content-Type": "application/json", + "CF-Connecting-IP": `10.1.0.${++_requestCounter}`, }, body: "not-valid-json{{{", }); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 258ea1de..4a27d6ed 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -370,6 +370,7 @@ describe("Worker /api/proxy/seal endpoint", () => { "Origin": ALLOWED_ORIGIN, "X-Requested-With": "fetch", "Content-Type": "application/json", + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, }, body: JSON.stringify({}), }); From e1fbcffda1bb91aa057b1df2131c5fdb10f79568 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 09:58:54 -0400 Subject: [PATCH 24/56] test(worker): adds missing-IP and missing-binding tests, updates docs - Tests: missing CF-Connecting-IP returns 400 (sentry, csp, oauth, seal) - Tests: missing PROXY_RATE_LIMITER binding returns 503 (oauth, seal) - DEPLOY.md: documents CF-Connecting-IP requirement, binding validation, CSP field sanitization, durable token exchange rate limiting, Content-Length byte ceilings --- DEPLOY.md | 13 ++++++++++++- tests/worker/csp-report.test.ts | 11 +++++++++++ tests/worker/oauth.test.ts | 21 +++++++++++++++++++++ tests/worker/seal.test.ts | 26 ++++++++++++++++++++++++++ tests/worker/sentry-tunnel.test.ts | 12 ++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/DEPLOY.md b/DEPLOY.md index e4fb6e13..ed6fbcb9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -111,13 +111,24 @@ Content-Length pre-check fires between origin check and DSN validation but is an Note: these counters are in-memory and do not survive isolate restarts. A fresh isolate gets a clean slate. CF isolates are short-lived, so this gates burst abuse within a single isolate lifetime — the intended use case. +Token exchange also uses the durable CF rate limiter binding (`PROXY_RATE_LIMITER`) with key `token:{ip}`, enforcing the limit globally across isolates. + +**`CF-Connecting-IP` requirement:** +All rate-limited endpoints require the `CF-Connecting-IP` header and return 400 if absent. Cloudflare's proxy layer sets this header on all production requests. Miniflare sets it to the connecting client's address in `wrangler dev`. There is no fallback — requests without this header are rejected outright. + +**`PROXY_RATE_LIMITER` binding validation:** +The Worker validates that the `PROXY_RATE_LIMITER` binding exists (via `typeof env.PROXY_RATE_LIMITER?.limit === "function"`) before calling it. A missing binding indicates a deployment misconfiguration (missing `[[ratelimits]]` in `wrangler.toml`) and returns 503. Transient `.limit()` errors on an existing binding fail open — the in-memory IP pre-gate still protects. + **Origin check behavior:** - Sentry tunnel (`/api/error-reporting`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. The Sentry SDK always includes `Origin` in its `fetch()` calls from our SPA. - CSP report tunnel (`/api/csp-report`): **soft** — allows absent Origin (browser CSP reports sent via the Reporting API may omit Origin), rejects only if Origin is present and does not match `ALLOWED_ORIGIN`. Note: Origin and Sec-Fetch-Site headers can be spoofed by programmatic clients (curl, scripts). IP rate limiting is the primary defense; origin checks are defense-in-depth. -**Content-Length pre-check:** +**CSP field sanitization:** +All string fields in CSP report bodies are sanitized before forwarding to Sentry: control characters (`\x00`–`\x08`, `\x0B`, `\x0C`, `\x0E`–`\x1F`, `\x7F`) are stripped and fields are capped at 2048 characters. This prevents log/SIEM injection via attacker-crafted CSP report values. URL fields are additionally scrubbed of OAuth parameters (`code`, `state`, `access_token`). + +**Content-Length pre-check (Sentry: 256 KB, CSP: 64 KB):** Both tunnel endpoints check the `Content-Length` header before reading the request body as an optimization. If the declared size exceeds the per-endpoint maximum, the request is rejected with 413 without buffering the body. The post-read size check remains the authoritative guard — Content-Length can be absent (chunked transfer, passes through) or spoofed (attacker declares small size but sends large body, caught by the post-read check). **CSP report fan-out amplification:** diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 81ee1743..5a3750ca 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -67,6 +67,17 @@ describe("Worker CSP report endpoint", () => { vi.restoreAllMocks(); }); + it("rejects requests without CF-Connecting-IP with 400", async () => { + const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { + method: "POST", + headers: { "Content-Type": "application/csp-report" }, + body, + }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(400); + }); + it("rejects non-POST requests", async () => { const req = new Request("https://gh.gordoncode.dev/api/csp-report", { method: "GET", diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index 43c614bb..f140a003 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -183,6 +183,27 @@ describe("Worker OAuth endpoint", () => { expect(failLog).toBeDefined(); }); + it("rejects token exchange with 400 when CF-Connecting-IP is absent", async () => { + const req = new Request("https://gh.gordoncode.dev/api/oauth/token", { + method: "POST", + headers: { "Origin": ALLOWED_ORIGIN, "Content-Type": "application/json" }, + body: JSON.stringify({ code: VALID_CODE }), + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("returns 503 when PROXY_RATE_LIMITER binding is missing", async () => { + const req = makeRequest("POST", "/api/oauth/token", { body: { code: VALID_CODE } }); + const env = makeEnv({ PROXY_RATE_LIMITER: undefined as unknown as Env["PROXY_RATE_LIMITER"] }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(503); + + const logs = collectLogs(consoleSpy); + const bindingLog = findLog(logs, "rate_limiter_binding_missing"); + expect(bindingLog).toBeDefined(); + }); + // ── Token exchange ───────────────────────────────────────────────────────── it("POST /api/oauth/token with valid code returns access_token, token_type, scope", async () => { diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 4a27d6ed..8872be10 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -453,6 +453,32 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(allLogText).not.toContain("ghp_abc123"); }); + // ── Missing CF-Connecting-IP and binding validation ──────────────────────── + + it("rejects proxy requests without CF-Connecting-IP with 400", async () => { + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: "ghp_test", purpose: "jira-api-token" }), + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("returns 503 when PROXY_RATE_LIMITER binding is missing", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + const req = makeSealRequest({ body: { token: "ghp_test", purpose: "jira-api-token" } }); + const env = makeEnv({ PROXY_RATE_LIMITER: undefined as unknown as Env["PROXY_RATE_LIMITER"] }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(503); + }); + // ── Proxy IP pre-gate ───────────────────────────────────────────────────── describe("proxy IP pre-gate", () => { diff --git a/tests/worker/sentry-tunnel.test.ts b/tests/worker/sentry-tunnel.test.ts index 76cdf8ff..75eb541f 100644 --- a/tests/worker/sentry-tunnel.test.ts +++ b/tests/worker/sentry-tunnel.test.ts @@ -67,6 +67,18 @@ describe("Sentry tunnel (/api/error-reporting)", () => { vi.restoreAllMocks(); }); + // ── Missing CF-Connecting-IP ─────────────────────────────────────────────── + + it("rejects requests without CF-Connecting-IP with 400", async () => { + const req = new Request("https://gh.gordoncode.dev/api/error-reporting", { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope", "Origin": ALLOWED_ORIGIN }, + body: makeEnvelope(VALID_DSN), + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + // ── Migrated tests from oauth.test.ts ───────────────────────────────────── it("forwards valid envelope to Sentry and returns Sentry's status code", async () => { From 783ad36c844a90e1894f51ff63f64a2bb66d3e38 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 10:48:36 -0400 Subject: [PATCH 25/56] docs(privacy): disclose session cookie, Turnstile, logging --- src/app/pages/PrivacyPage.tsx | 106 +++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/src/app/pages/PrivacyPage.tsx b/src/app/pages/PrivacyPage.tsx index 410fffc8..35fe8653 100644 --- a/src/app/pages/PrivacyPage.tsx +++ b/src/app/pages/PrivacyPage.tsx @@ -35,6 +35,101 @@ export default function PrivacyPage() { +

+ Cookies +

+

+ A single session cookie (__Host-session) is set when + you use the proxy features of this app (such as sealing an API + token). This cookie exists solely to bind API rate limits to a + browser session — it is not used for tracking or authentication. +

+
    +
  • + Attributes: HttpOnly, Secure, SameSite=Strict, + with the __Host- prefix enforced by the browser +
  • +
  • + Lifetime: expires automatically after 8 hours +
  • +
  • + Content: a random session ID only — no personal + data, no access tokens, no identifiers linked to your account +
  • +
+

+ This cookie does not track you across sites or sessions. It is a + strictly necessary security measure and does not require consent + under GDPR. +

+ +

+ Bot protection +

+

+ We use{" "} + + Cloudflare Turnstile + {" "} + (Cloudflare, Inc.) to distinguish human users from bots during + token-sealing operations. When Turnstile is invoked, Cloudflare + collects the following signals client-side: your IP address, TLS + fingerprint, user-agent header, and browser characteristics. +

+

+ Our server also forwards your IP address to Cloudflare's{" "} + siteverify API via the remoteip field to + improve bot-detection accuracy. Your IP is{" "} + not stored or logged by our server — it is only + forwarded to Cloudflare for this single verification request. +

+

+ Turnstile does not use tracking cookies, build user + profiles, or perform cross-site tracking. Cloudflare acts as a data + processor for service delivery and as a data controller for improving + bot detection. See the{" "} + + Cloudflare Turnstile Privacy Addendum + {" "} + for details. +

+ +

+ Server-side logging +

+

+ Our Cloudflare Worker logs metadata about API requests for security + monitoring and abuse detection. The following fields are logged per + request: +

+
    +
  • Request origin and user-agent header
  • +
  • + Cloudflare datacenter location (country, city, and datacenter + code) +
  • +
+

+ What is not logged: IP addresses are not stored, + request or response bodies, API tokens, OAuth authorization codes, or + cookie values. +

+

+ Logs are automatically deleted after 7 days (Cloudflare Workers Logs + retention). They are used only for security monitoring and abuse + detection — never for analytics, profiling, or tracking. +

+

Error monitoring

@@ -63,6 +158,12 @@ export default function PrivacyPage() { screen recordings, keystrokes, or performance traces. All sensitive URL parameters are stripped before data leaves your browser.

+

+ Server-side errors (from the Cloudflare Worker) are also reported to + Sentry directly, applying the same data minimization practices: no + PII, no request bodies, and no headers are included in worker error + events. +

Error data is stored on Sentry's US-based infrastructure and retained per Sentry's{" "} @@ -81,7 +182,10 @@ export default function PrivacyPage() {

  • No analytics or behavioral tracking
  • -
  • No cookies
  • +
  • + No tracking cookies — one security-only session cookie (described + above) is used solely for rate-limit binding +
  • No session recordings or screen capture
  • No user identification or profiling
From 71aacbee4850c681fe1b5aee5fb250c25fa38fdc Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 10:50:42 -0400 Subject: [PATCH 26/56] fix(sentry): wraps ErrorBoundary with Sentry capture for render errors --- src/app/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index e56dd28a..a86f133f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,5 @@ import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { Router, Route, Navigate, useNavigate } from "@solidjs/router"; import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth"; import { config, initConfigPersistence, resolveTheme } from "./stores/config"; @@ -14,6 +15,8 @@ const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage")) const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard")); const SettingsPage = lazy(() => import("./components/settings/SettingsPage")); +const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); + function handleRouteError(err: unknown) { console.error("[app] Route render failed:", err); return ; @@ -182,7 +185,7 @@ export default function App() { }); return ( - + @@ -199,6 +202,6 @@ export default function App() { } /> - + ); } From 4daf145394b83556b6e306f720b25aa515cd3587 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 10:54:15 -0400 Subject: [PATCH 27/56] fix(sentry): adds captureException to catch blocks, expands breadcrumbs --- src/app/lib/sentry.ts | 8 +++++++- src/app/services/api.ts | 10 +++++++++- src/app/services/poll.ts | 4 ++++ src/app/stores/auth.ts | 6 ++++-- src/app/stores/cache.ts | 4 +++- tests/lib/sentry.test.ts | 9 +++++++-- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 0aa87aef..10b572d9 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -17,6 +17,11 @@ const ALLOWED_CONSOLE_PREFIXES = [ "[poll]", "[dashboard]", "[settings]", + "[hot-poll]", + "[cache]", + "[github]", + "[mcp-relay]", + "[notifications]", ]; const SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240"; @@ -32,9 +37,10 @@ export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { ? scrubUrl(event.request.query_string) : "[REDACTED]"; } - // Remove headers and cookies entirely + // Remove headers, cookies, and request body entirely delete event.request?.headers; delete event.request?.cookies; + delete event.request?.data; // Remove user identity — we never want to track users delete event.user; // Scrub URLs in stack trace frames diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 4a1e26d6..901baac4 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1,4 +1,5 @@ import { getClient, cachedRequest, updateGraphqlRateLimit } from "./github"; +import * as Sentry from "@sentry/solid"; import { pushNotification } from "../lib/errors"; import type { ApiCallSource } from "./api-usage"; import type { TrackedUser } from "../stores/config"; @@ -863,7 +864,13 @@ async function executeLightCombinedQuery( )); } if (prPaginationTasks.length > 0) { - await Promise.allSettled(prPaginationTasks); + const paginationSettled = await Promise.allSettled(prPaginationTasks); + for (const s of paginationSettled) { + if (s.status === "rejected") { + console.warn("[api] PR pagination task failed:", s.reason); + Sentry.captureException(s.reason, { tags: { source: "pr-pagination" } }); + } + } } } @@ -1678,6 +1685,7 @@ export async function fetchHotPRStatus( if (s.status === "rejected") { hadErrors = true; console.warn("[hot-poll] PR status batch failed:", s.reason); + Sentry.captureException(s.reason, { tags: { source: "hot-poll-pr-batch" } }); } } diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 0e9056fa..3aca7658 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -1,4 +1,5 @@ import { createSignal, createEffect, createRoot, untrack, onCleanup } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { getClient } from "./github"; import { config } from "../stores/config"; import { user, onAuthCleared } from "../stores/auth"; @@ -544,6 +545,7 @@ export async function fetchHotData(): Promise<{ } catch (err) { hadErrors = true; console.warn("[hot-poll] PR status fetch failed:", err); + Sentry.captureException(err, { tags: { source: "hot-poll-pr-fetch" } }); // Items stay in _hotPRs for retry next cycle } @@ -558,6 +560,8 @@ export async function fetchHotData(): Promise<{ runUpdates.set(result.value.id, result.value); } else { hadErrors = true; + console.warn("[hot-poll] Workflow run fetch failed:", result.reason); + Sentry.captureException(result.reason, { tags: { source: "hot-poll-run-fetch" } }); } } diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index e60c3172..4c14fe19 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -1,4 +1,5 @@ import { createSignal } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { clearCache } from "./cache"; import { CONFIG_STORAGE_KEY, resetConfig, updateConfig, config } from "./config"; import { VIEW_STORAGE_KEY, resetViewState } from "./view"; @@ -81,8 +82,9 @@ export function clearAuth(): void { _setToken(null); setUser(null); // Clear IndexedDB cache to prevent data leakage between users (SDR-016) - clearCache().catch(() => { - // Non-fatal — cache clear failure should not block logout + clearCache().catch((err) => { + console.warn("[auth] Cache clear failed during logout:", err); + Sentry.captureException(err, { tags: { source: "auth-logout-cache-clear" } }); }); // Run registered cleanup callbacks (e.g., poll state reset) for (const cb of _onClearCallbacks) { diff --git a/src/app/stores/cache.ts b/src/app/stores/cache.ts index c2ced352..d7e1bd83 100644 --- a/src/app/stores/cache.ts +++ b/src/app/stores/cache.ts @@ -1,4 +1,5 @@ import { openDB, type IDBPDatabase } from "idb"; +import * as Sentry from "@sentry/solid"; export interface CacheEntry { key: string; @@ -68,8 +69,9 @@ export async function setCacheEntry( try { const db = await getDb(); await db.put("cache", entry); - } catch { + } catch (err) { console.warn("[cache] Still over quota after emergency eviction — entry dropped"); + Sentry.captureException(err, { tags: { source: "cache-eviction-retry" } }); } } else { throw err; diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 23f94f99..098208ff 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -85,17 +85,19 @@ describe("beforeSendHandler", () => { expect(result!.request!.query_string).toBe("[REDACTED]"); }); - it("deletes request headers and cookies", () => { + it("deletes request headers, cookies, and data", () => { const event = { request: { url: "https://gh.gordoncode.dev", headers: { Authorization: "Bearer ghu_token" }, cookies: "session=abc", + data: '{"token":"ghu_secret"}', }, }; const result = beforeSendHandler(event as never); expect(result!.request!.headers).toBeUndefined(); expect(result!.request!.cookies).toBeUndefined(); + expect((result!.request as Record).data).toBeUndefined(); }); it("deletes user identity", () => { @@ -179,7 +181,10 @@ describe("beforeBreadcrumbHandler", () => { }); it("keeps allowed console breadcrumbs", () => { - const prefixes = ["[app]", "[auth]", "[api]", "[poll]", "[dashboard]", "[settings]"]; + const prefixes = [ + "[app]", "[auth]", "[api]", "[poll]", "[dashboard]", "[settings]", + "[hot-poll]", "[cache]", "[github]", "[mcp-relay]", "[notifications]", + ]; for (const prefix of prefixes) { const breadcrumb = { category: "console", From 50909bd27adcc7763e6f0780f0dcd43729526c10 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 11:01:35 -0400 Subject: [PATCH 28/56] feat(worker): adds @sentry/cloudflare for worker-side error capture --- DEPLOY.md | 3 +- package.json | 1 + pnpm-lock.yaml | 51 ++++++--- src/worker/index.ts | 21 +++- src/worker/sentry.ts | 99 ++++++++++++++++ src/worker/session.ts | 2 + tests/lib/worker-sentry.test.ts | 192 ++++++++++++++++++++++++++++++++ tests/worker/setup.ts | 7 ++ vitest.workspace.ts | 1 + wrangler.toml | 4 +- 10 files changed, 363 insertions(+), 18 deletions(-) create mode 100644 src/worker/sentry.ts create mode 100644 tests/lib/worker-sentry.test.ts create mode 100644 tests/worker/setup.ts diff --git a/DEPLOY.md b/DEPLOY.md index ed6fbcb9..c42eaebf 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -90,7 +90,7 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i ### CORS - `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards) -- No `Access-Control-Allow-Credentials` header (OAuth App uses no cookies) +- No `Access-Control-Allow-Credentials` header (the `__Host-session` cookie is SameSite=Strict and is not relevant to cross-origin requests) ### Tunnel Endpoint Security @@ -239,6 +239,7 @@ wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard ``` - `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`. +- `SENTRY_DSN` (configured in `wrangler.toml` `[vars]`, not a secret): used by both the Sentry tunnel endpoint for DSN validation and the `@sentry/cloudflare` SDK for direct worker-side error capture. Sentry DSNs are public keys — they authorize sending events, not reading them. - `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. - `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key). - `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key). diff --git a/package.json b/package.json index e38fc371..a9ea82e2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@octokit/plugin-paginate-rest": "14.0.0", "@octokit/plugin-retry": "8.1.0", "@octokit/plugin-throttling": "11.0.3", + "@sentry/cloudflare": "10.46.0", "@sentry/solid": "10.46.0", "@solidjs/router": "0.16.1", "idb": "8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 328633f4..7fdc6ba7 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/cloudflare': + specifier: 10.46.0 + version: 10.46.0 '@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) @@ -41,16 +44,16 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: 6.3.0 - version: 6.3.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) + version: 6.3.0(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) '@cloudflare/vite-plugin': specifier: 1.30.1 version: 1.30.1(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))(workerd@1.20260317.1)(wrangler@4.77.0) '@cloudflare/vitest-pool-workers': specifier: 0.13.4 - version: 0.13.4(@vitest/runner@4.1.1)(@vitest/snapshot@4.1.1)(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) + version: 0.13.4(@vitest/runner@4.1.1)(@vitest/snapshot@4.1.1)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) '@fast-check/vitest': specifier: ^0.4.0 - version: 0.4.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) + version: 0.4.0(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))) '@playwright/test': specifier: 1.58.2 version: 1.58.2 @@ -86,7 +89,7 @@ importers: version: 2.11.11(solid-js@1.9.11)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) vitest: specifier: 4.1.1 - version: 4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) wrangler: specifier: 4.77.0 version: 4.77.0 @@ -129,7 +132,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.0 - version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) packages: @@ -723,6 +726,10 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} @@ -996,6 +1003,15 @@ packages: resolution: {integrity: sha512-80DmGlTk5Z2/OxVOzLNxwolMyouuAYKqG8KUcoyintZqHbF6kO1RulI610HmyUt3OagKeBCqt9S7w0VIfCRL+Q==} engines: {node: '>=18'} + '@sentry/cloudflare@10.46.0': + resolution: {integrity: sha512-gN+S56kStf8jvutSQ+RCkapB8YgVXAmXLddDsbO8Oz5G1ts7Af6QLqSS4FoSGF/JLdV8QFMmBLBhx0P/KD3ngw==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@sentry/core@10.46.0': resolution: {integrity: sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q==} engines: {node: '>=18'} @@ -2479,13 +2495,13 @@ packages: snapshots: - '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': + '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': dependencies: callsites: 4.2.0 minimist: 1.2.8 parsecurrency: 1.1.1 ts-morph: 27.0.2 - vitest: 4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) '@babel/code-frame@7.29.0': dependencies: @@ -2621,14 +2637,14 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vitest-pool-workers@0.13.4(@vitest/runner@4.1.1)(@vitest/snapshot@4.1.1)(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': + '@cloudflare/vitest-pool-workers@0.13.4(@vitest/runner@4.1.1)(@vitest/snapshot@4.1.1)(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': dependencies: '@vitest/runner': 4.1.1 '@vitest/snapshot': 4.1.1 cjs-module-lexer: 1.4.3 esbuild: 0.27.3 miniflare: 4.20260317.2 - vitest: 4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) wrangler: 4.77.0 zod: 3.25.76 transitivePeerDependencies: @@ -2754,10 +2770,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@fast-check/vitest@0.4.0(vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': + '@fast-check/vitest@0.4.0(vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)))': dependencies: fast-check: 4.6.0 - vitest: 4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) '@floating-ui/core@1.7.5': dependencies: @@ -3014,6 +3030,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@opentelemetry/api@1.9.1': {} + '@oxc-project/types@0.122.0': {} '@playwright/test@1.58.2': @@ -3182,6 +3200,11 @@ snapshots: '@sentry-internal/replay-canvas': 10.46.0 '@sentry/core': 10.46.0 + '@sentry/cloudflare@10.46.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@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)': @@ -4484,7 +4507,7 @@ snapshots: optionalDependencies: vite: 8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0) - vitest@4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) @@ -4507,12 +4530,13 @@ snapshots: vite: 8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.5.0 happy-dom: 20.8.9 transitivePeerDependencies: - msw - vitest@4.1.1(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.1 '@vitest/mocker': 4.1.1(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) @@ -4535,6 +4559,7 @@ snapshots: vite: 8.0.5(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.5.0 happy-dom: 20.8.9 transitivePeerDependencies: diff --git a/src/worker/index.ts b/src/worker/index.ts index 9d835b93..46eee8f4 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,7 +1,15 @@ +import { withSentry } from "@sentry/cloudflare"; +import * as Sentry from "@sentry/cloudflare"; import { CryptoEnv, deriveKey, sealToken, SEAL_SALT } from "./crypto"; import { SessionEnv, ensureSession } from "./session"; import { TurnstileEnv, verifyTurnstile, extractTurnstileToken } from "./turnstile"; import { validateProxyRequest, validateOrigin } from "./validation"; +import { getWorkerSentryOptions } from "./sentry"; + +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} interface RateLimiter { limit(options: { key: string }): Promise<{ success: boolean }>; @@ -284,6 +292,7 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P log("error", "seal_failed", { error: err instanceof Error ? err.message : "unknown", }, request); + Sentry.captureException(err, { tags: { source: "worker-seal" } }); return errorResponse("seal_failed", 500); } @@ -452,6 +461,7 @@ async function handleSentryTunnel( log("error", "sentry_tunnel_fetch_failed", { error: err instanceof Error ? err.message : "unknown", }, request); + Sentry.captureException(err, { tags: { source: "worker-sentry-tunnel" } }); return new Response(null, { status: 502, headers: SECURITY_HEADERS }); } } @@ -728,6 +738,7 @@ async function handleTokenExchange( error: err instanceof Error ? err.message : "unknown", error_name: err instanceof Error ? err.name : "unknown", }, request); + Sentry.captureException(err, { tags: { source: "worker-token-exchange" } }); return errorResponse("token_exchange_failed", 400, cors); } @@ -769,8 +780,10 @@ async function handleTokenExchange( }); } -export default { - async fetch(request: Request, env: Env): Promise { +export default withSentry( + (env: Env) => getWorkerSentryOptions(env), + { + async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { const url = new URL(request.url); const origin = request.headers.get("Origin"); const cors = getCorsHeaders(origin, env.ALLOWED_ORIGIN); @@ -866,6 +879,7 @@ export default { log("error", "rate_limiter_failed", { error: err instanceof Error ? err.message : "unknown", }, request); + Sentry.captureException(err, { tags: { source: "worker-rate-limiter" } }); } if (rateLimited) { log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); @@ -906,4 +920,5 @@ export default { // Forward non-API requests to static assets return env.ASSETS.fetch(request); }, -}; + } +); diff --git a/src/worker/sentry.ts b/src/worker/sentry.ts new file mode 100644 index 00000000..0979b827 --- /dev/null +++ b/src/worker/sentry.ts @@ -0,0 +1,99 @@ +import { requestDataIntegration, type CloudflareOptions } from "@sentry/cloudflare"; + +// Minimal event interface — avoids transitive SDK type imports in test files. +// query_string is string | unknown[] in the Sentry SDK (QueryParams type). +interface WorkerSentryEvent { + request?: { + url?: string; + query_string?: string | unknown; + headers?: unknown; + cookies?: unknown; + data?: unknown; + }; + user?: unknown; + exception?: { + values?: Array<{ + value?: string; + stacktrace?: { + frames?: Array<{ abs_path?: string }>; + }; + }>; + }; +} + +interface SentryEnv { + SENTRY_DSN?: string; +} + +/** Strip OAuth credentials and client_secret from any captured URL or string. */ +function scrubSensitive(s: string): string { + return s + .replace(/code=[^&\s"]+/g, "code=[REDACTED]") + .replace(/state=[^&\s"]+/g, "state=[REDACTED]") + .replace(/access_token=[^&\s"]+/g, "access_token=[REDACTED]") + .replace(/client_secret=[^&\s"]+/g, "client_secret=[REDACTED]") + .replace(/"client_secret":"[^"]+"/g, '"client_secret":"[REDACTED]"') + .replace(/\bghu_[A-Za-z0-9_]+/g, "ghu_[REDACTED]") + .replace(/\bghp_[A-Za-z0-9_]+/g, "ghp_[REDACTED]") + .replace(/\bgho_[A-Za-z0-9_]+/g, "gho_[REDACTED]"); +} + +export function workerBeforeSendHandler( + event: WorkerSentryEvent +): WorkerSentryEvent | null { + // Strip OAuth params and secrets from captured URLs + if (event.request?.url) { + event.request.url = scrubSensitive(event.request.url); + } + if (event.request?.query_string) { + event.request.query_string = + typeof event.request.query_string === "string" + ? scrubSensitive(event.request.query_string) + : "[REDACTED]"; + } + + // Delete headers, cookies, and request body entirely — may contain + // Authorization, Cookie, CF-Connecting-IP, and sealed API tokens + delete event.request?.headers; + delete event.request?.cookies; + delete event.request?.data; + + // Remove user identity + delete event.user; + + // Scrub stack trace frame abs_path values + 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 = scrubSensitive(frame.abs_path); + } + } + } + // Scrub exception message strings — defense-in-depth for token leakage + if (ex.value) { + ex.value = scrubSensitive(ex.value); + } + } + } + + return event; +} + +export function getWorkerSentryOptions(env: SentryEnv): CloudflareOptions { + return { + dsn: env.SENTRY_DSN, + environment: "production", + sendDefaultPii: false, + tracesSampleRate: 0, + // Cast: workerBeforeSendHandler uses a minimal local interface for testability + // but is fully compatible with ErrorEvent at runtime. + beforeSend: workerBeforeSendHandler as CloudflareOptions["beforeSend"], + integrations: [ + requestDataIntegration({ + include: { headers: false, cookies: false, data: false }, + }), + ], + }; +} diff --git a/src/worker/session.ts b/src/worker/session.ts index b7d48ccb..7e6cb79a 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -9,6 +9,7 @@ // `wrangler dev --local-protocol https` to test session cookies locally. // See DEPLOY.md "## Local Development" for details. +import * as Sentry from "@sentry/cloudflare"; import { deriveKey, signSession, @@ -149,6 +150,7 @@ export async function ensureSession( event: "session_issue_failed", error: error instanceof Error ? error.message : "unknown", })); + Sentry.captureException(error, { tags: { source: "worker-session-issue" } }); return { sessionId: crypto.randomUUID() }; } } diff --git a/tests/lib/worker-sentry.test.ts b/tests/lib/worker-sentry.test.ts new file mode 100644 index 00000000..45e07fbb --- /dev/null +++ b/tests/lib/worker-sentry.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock @sentry/cloudflare to prevent resolution failure in happy-dom pool. +// workerBeforeSendHandler is a pure function with no Cloudflare API deps. +vi.mock("@sentry/cloudflare", () => ({ + requestDataIntegration: vi.fn(() => ({})), +})); + +import { + workerBeforeSendHandler, + getWorkerSentryOptions, +} from "../../src/worker/sentry"; + +describe("workerBeforeSendHandler", () => { + it("scrubs OAuth params from request URL", () => { + const event = { + request: { url: "https://example.com/cb?code=abc123&state=xyz" }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.url).toBe( + "https://example.com/cb?code=[REDACTED]&state=[REDACTED]" + ); + }); + + it("scrubs access_token from request URL", () => { + const event = { + request: { url: "https://example.com?access_token=ghu_secret" }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.url).toBe( + "https://example.com?access_token=[REDACTED]" + ); + }); + + it("scrubs query_string when it is a string", () => { + const event = { + request: { + url: "https://example.com/cb", + query_string: "code=abc123&tab=issues", + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.query_string).toBe("code=[REDACTED]&tab=issues"); + }); + + it("redacts query_string entirely when not a string", () => { + const event = { + request: { + url: "https://example.com/cb", + query_string: [["code", "abc"]], + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.query_string).toBe("[REDACTED]"); + }); + + it("deletes request headers from event", () => { + const event = { + request: { + url: "https://example.com", + headers: { Authorization: "Bearer ghu_token", Cookie: "session=abc" }, + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.headers).toBeUndefined(); + }); + + it("deletes request cookies from event", () => { + const event = { + request: { + url: "https://example.com", + cookies: { "__Host-session": "abc123" }, + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.cookies).toBeUndefined(); + }); + + it("deletes request data (body) from event", () => { + const event = { + request: { + url: "https://example.com/api/proxy/seal", + data: '{"token":"ghu_secret123","purpose":"jira-api-token"}', + }, + }; + const result = workerBeforeSendHandler(event); + expect((result!.request as Record).data).toBeUndefined(); + }); + + it("deletes user identity from event", () => { + const event = { + request: { url: "https://example.com" }, + user: { id: "123", email: "user@example.com" }, + }; + const result = workerBeforeSendHandler(event); + expect((result as Record).user).toBeUndefined(); + }); + + it("scrubs stack trace abs_path values", () => { + const event = { + request: { url: "https://example.com" }, + exception: { + values: [ + { + stacktrace: { + frames: [ + { abs_path: "https://example.com/worker.js?code=secret" }, + { abs_path: "https://example.com/lib.js" }, + ], + }, + }, + ], + }, + }; + const result = workerBeforeSendHandler(event); + const frames = result!.exception!.values![0].stacktrace!.frames!; + expect(frames[0].abs_path).toBe( + "https://example.com/worker.js?code=[REDACTED]" + ); + expect(frames[1].abs_path).toBe("https://example.com/lib.js"); + }); + + it("scrubs client_secret pattern from exception message", () => { + const event = { + request: { url: "https://example.com" }, + exception: { + values: [ + { + value: + 'Fetch failed: client_secret=supersecret123 "client_secret":"anothersecret"', + }, + ], + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.exception!.values![0].value).toBe( + 'Fetch failed: client_secret=[REDACTED] "client_secret":"[REDACTED]"' + ); + }); + + it("scrubs GitHub token prefixes (ghu_, ghp_, gho_) from exception message", () => { + const event = { + request: { url: "https://example.com" }, + exception: { + values: [ + { + value: "Token ghu_abc123 or ghp_xyz789 or gho_def456 exposed", + }, + ], + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.exception!.values![0].value).toBe( + "Token ghu_[REDACTED] or ghp_[REDACTED] or gho_[REDACTED] exposed" + ); + }); + + it("passes through events without request field", () => { + const event = {}; + const result = workerBeforeSendHandler(event); + expect(result).toBeDefined(); + expect(result).toEqual({}); + }); +}); + +describe("getWorkerSentryOptions", () => { + it("returns correct requestDataIntegration config", async () => { + const { requestDataIntegration } = await import("@sentry/cloudflare"); + const env = { SENTRY_DSN: "https://key@sentry.io/123" }; + getWorkerSentryOptions(env); + expect(requestDataIntegration).toHaveBeenCalledWith({ + include: { headers: false, cookies: false, data: false }, + }); + }); + + it("uses SENTRY_DSN from env", () => { + const env = { SENTRY_DSN: "https://key@sentry.io/456" }; + const opts = getWorkerSentryOptions(env); + expect(opts.dsn).toBe("https://key@sentry.io/456"); + }); + + it("disables PII and tracing", () => { + const opts = getWorkerSentryOptions({}); + expect(opts.sendDefaultPii).toBe(false); + expect(opts.tracesSampleRate).toBe(0); + }); + + it("sets environment to production", () => { + const opts = getWorkerSentryOptions({}); + expect(opts.environment).toBe("production"); + }); +}); diff --git a/tests/worker/setup.ts b/tests/worker/setup.ts new file mode 100644 index 00000000..1d0b7481 --- /dev/null +++ b/tests/worker/setup.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest"; + +vi.mock("@sentry/cloudflare", () => ({ + withSentry: (_opts: unknown, handler: { fetch: unknown }) => handler, + captureException: vi.fn(), + requestDataIntegration: vi.fn(() => ({})), +})); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 595997ba..b809d1ba 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -31,6 +31,7 @@ export default defineConfig({ test: { name: "worker", globals: true, + setupFiles: ["tests/worker/setup.ts"], include: ["tests/worker/**/*.test.ts"], }, }), diff --git a/wrangler.toml b/wrangler.toml index b3b0211b..7972e080 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,7 +1,7 @@ name = "github-tracker" main = "src/worker/index.ts" compatibility_date = "2026-03-01" -compatibility_flags = ["global_fetch_strictly_public"] +compatibility_flags = ["global_fetch_strictly_public", "nodejs_als"] workers_dev = false [assets] @@ -16,6 +16,8 @@ pattern = "gh.gordoncode.dev" custom_domain = true [vars] +# SENTRY_DSN is used by both the Sentry tunnel endpoint (DSN validation) and the +# @sentry/cloudflare SDK for worker-side error capture. No additional secrets needed. SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240" [[ratelimits]] From 0f2a7521de31299fd1a2aaaa233ab09ff6a8aabd Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 11:26:51 -0400 Subject: [PATCH 29/56] =?UTF-8?q?fix(sentry):=20address=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20browser=20scrubbing=20parity,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/lib/sentry.ts | 15 +- src/app/pages/PrivacyPage.tsx | 6 +- src/worker/index.ts | 242 ++++++++++++++++---------------- src/worker/sentry.ts | 5 +- tests/lib/sentry.test.ts | 33 +++++ tests/lib/worker-sentry.test.ts | 20 ++- wrangler.toml | 6 +- 7 files changed, 195 insertions(+), 132 deletions(-) diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 10b572d9..154c2c38 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -1,12 +1,14 @@ import * as Sentry from "@sentry/solid"; import type { ErrorEvent, Breadcrumb } from "@sentry/solid"; -/** Strip OAuth credentials from any captured URL or query string. */ +/** Strip OAuth credentials and tokens 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]"); + .replace(/access_token=[^&\s]+/g, "access_token=[REDACTED]") + .replace(/client_secret=[^&\s"]+/gi, "client_secret=[FILTERED]") + .replace(/\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g, "$1[FILTERED]"); } /** Allowed console breadcrumb prefixes — drop everything else. */ @@ -43,7 +45,7 @@ export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { delete event.request?.data; // Remove user identity — we never want to track users delete event.user; - // Scrub URLs in stack trace frames + // Scrub URLs in stack trace frames and exception messages if (event.exception?.values) { for (const ex of event.exception.values) { if (ex.stacktrace?.frames) { @@ -53,6 +55,10 @@ export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { } } } + // Scrub exception message strings — defense-in-depth for token leakage + if (ex.value) { + ex.value = scrubUrl(ex.value); + } } } return event; @@ -97,8 +103,7 @@ export function initSentry(): void { // ── Privacy: absolute minimum data ────────────────────────── sendDefaultPii: false, - // ── Disable everything except error tracking ──────────────── - tracesSampleRate: 0, + // ── Disable performance tracing (tracesSampleRate omitted = undefined = no spans) ─── profilesSampleRate: 0, // ── Only capture errors from our own code ─────────────────── diff --git a/src/app/pages/PrivacyPage.tsx b/src/app/pages/PrivacyPage.tsx index 35fe8653..8ae75ccd 100644 --- a/src/app/pages/PrivacyPage.tsx +++ b/src/app/pages/PrivacyPage.tsx @@ -120,9 +120,9 @@ export default function PrivacyPage() {

- What is not logged: IP addresses are not stored, - request or response bodies, API tokens, OAuth authorization codes, or - cookie values. + What is not logged: IP addresses, request or + response bodies, API tokens, OAuth authorization codes, and cookie + values are never stored.

Logs are automatically deleted after 7 days (Cloudflare Workers Logs diff --git a/src/worker/index.ts b/src/worker/index.ts index 46eee8f4..62d8c563 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -783,142 +783,142 @@ async function handleTokenExchange( export default withSentry( (env: Env) => getWorkerSentryOptions(env), { - async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { - 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, + async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { + 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); - } - } - - // 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", ...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); - } - - // 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); - } - if (url.pathname === "/api/health" && request.method === "GET") { - return new Response("OK", { - headers: SECURITY_HEADERS, - }); - } + if (!corsMatched && origin !== null) { + log("warn", "cors_origin_mismatch", { + request_origin: origin, + allowed_origin: env.ALLOWED_ORIGIN, + }, request); + } + } - // ── Proxy routes: validation, session, and rate limiting ───────────────── - // Applies to /api/proxy/*, /api/jira/* - // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. - // Proxy routes assume SPA fetch() callers — browser navigation GETs do not send Origin. - if (isProxyPath(url.pathname)) { - const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname); - if (guardResponse !== null) return guardResponse; - - // Step 2.5: IP pre-gate — rejects burst abuse before any crypto work (HKDF, HMAC) - const proxyIp = getClientIp(request); - if (!proxyIp) { - return new Response(JSON.stringify({ error: "invalid_request" }), { - status: 400, - headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + // 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", ...SECURITY_HEADERS }, }); } - if (!proxyPreGateLimiter.check(proxyIp)) { - log("warn", "proxy_ip_rate_limited", { pathname: url.pathname }, request); - return new Response(JSON.stringify({ error: "rate_limited" }), { - status: 429, - headers: { "Content-Type": "application/json", "Retry-After": "60", ...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); } - // Step 3: Session middleware — ensureSession never throws (SDR-003) - const { sessionId, setCookie } = await ensureSession(request, env); - - // Step 4: Durable rate limiting using session ID as key. - // Missing binding = deployment bug → fail closed (503). - // Transient .limit() error on existing binding → fail open (IP pre-gate still protects). - let rateLimited = false; - if (typeof env.PROXY_RATE_LIMITER?.limit !== "function") { - log("error", "rate_limiter_binding_missing", {}, request); - const r503 = errorResponse("internal_error", 503); - const h503 = new Headers(r503.headers); - if (setCookie) h503.set("Set-Cookie", setCookie); - return new Response(r503.body, { status: 503, headers: h503 }); + // CSP report tunnel — scrubs OAuth params before forwarding to Sentry + if (url.pathname === "/api/csp-report") { + return handleCspReport(request, env); } - try { - const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); - rateLimited = !success; - } catch (err) { - log("error", "rate_limiter_failed", { - error: err instanceof Error ? err.message : "unknown", - }, request); - Sentry.captureException(err, { tags: { source: "worker-rate-limiter" } }); + + if (url.pathname === "/api/oauth/token") { + return handleTokenExchange(request, env, cors); } - if (rateLimited) { - log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); - const headers: Record = { - "Content-Type": "application/json", - "Retry-After": "60", - ...SECURITY_HEADERS, - }; - if (setCookie) headers["Set-Cookie"] = setCookie; - return new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, headers }); + + if (url.pathname === "/api/health" && request.method === "GET") { + return new Response("OK", { + headers: SECURITY_HEADERS, + }); } - // Step 5: Sealed-token endpoint - if (url.pathname === "/api/proxy/seal") { - const sealResponse = await handleProxySeal(request, env, sessionId); - if (setCookie) { - const headers = new Headers(sealResponse.headers); - headers.set("Set-Cookie", setCookie); - return new Response(sealResponse.body, { - status: sealResponse.status, - headers, + // ── Proxy routes: validation, session, and rate limiting ───────────────── + // Applies to /api/proxy/*, /api/jira/* + // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. + // Proxy routes assume SPA fetch() callers — browser navigation GETs do not send Origin. + if (isProxyPath(url.pathname)) { + const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname); + if (guardResponse !== null) return guardResponse; + + // Step 2.5: IP pre-gate — rejects burst abuse before any crypto work (HKDF, HMAC) + const proxyIp = getClientIp(request); + if (!proxyIp) { + return new Response(JSON.stringify({ error: "invalid_request" }), { + status: 400, + headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + }); + } + if (!proxyPreGateLimiter.check(proxyIp)) { + log("warn", "proxy_ip_rate_limited", { pathname: url.pathname }, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "60", ...SECURITY_HEADERS }, }); } - return sealResponse; - } - // Other proxy routes not yet implemented — fall through to 404 - } + // Step 3: Session middleware — ensureSession never throws (SDR-003) + const { sessionId, setCookie } = await ensureSession(request, env); + + // Step 4: Durable rate limiting using session ID as key. + // Missing binding = deployment bug → fail closed (503). + // Transient .limit() error on existing binding → fail open (IP pre-gate still protects). + let rateLimited = false; + if (typeof env.PROXY_RATE_LIMITER?.limit !== "function") { + log("error", "rate_limiter_binding_missing", {}, request); + const r503 = errorResponse("internal_error", 503); + const h503 = new Headers(r503.headers); + if (setCookie) h503.set("Set-Cookie", setCookie); + return new Response(r503.body, { status: 503, headers: h503 }); + } + try { + const { success } = await env.PROXY_RATE_LIMITER.limit({ key: sessionId }); + rateLimited = !success; + } catch (err) { + log("error", "rate_limiter_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-rate-limiter" } }); + } + if (rateLimited) { + log("warn", "proxy_rate_limited", { pathname: url.pathname }, request); + const headers: Record = { + "Content-Type": "application/json", + "Retry-After": "60", + ...SECURITY_HEADERS, + }; + if (setCookie) headers["Set-Cookie"] = setCookie; + return new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, headers }); + } - if (url.pathname.startsWith("/api/")) { - log("warn", "api_not_found", { - method: request.method, - pathname: url.pathname, - }, request); - return errorResponse("not_found", 404, cors); - } + // Step 5: Sealed-token endpoint + if (url.pathname === "/api/proxy/seal") { + const sealResponse = await handleProxySeal(request, env, sessionId); + if (setCookie) { + const headers = new Headers(sealResponse.headers); + headers.set("Set-Cookie", setCookie); + return new Response(sealResponse.body, { + status: sealResponse.status, + headers, + }); + } + return sealResponse; + } + + // Other proxy routes not yet implemented — fall through to 404 + } - // Forward non-API requests to static assets - return env.ASSETS.fetch(request); - }, + if (url.pathname.startsWith("/api/")) { + log("warn", "api_not_found", { + method: request.method, + pathname: url.pathname, + }, request); + return errorResponse("not_found", 404, cors); + } + + // Forward non-API requests to static assets + return env.ASSETS.fetch(request); + }, } ); diff --git a/src/worker/sentry.ts b/src/worker/sentry.ts index 0979b827..dc3c8fea 100644 --- a/src/worker/sentry.ts +++ b/src/worker/sentry.ts @@ -86,10 +86,13 @@ export function getWorkerSentryOptions(env: SentryEnv): CloudflareOptions { dsn: env.SENTRY_DSN, environment: "production", sendDefaultPii: false, - tracesSampleRate: 0, + // tracesSampleRate omitted (undefined) — hasSpansEnabled() returns false, no span overhead // Cast: workerBeforeSendHandler uses a minimal local interface for testability // but is fully compatible with ErrorEvent at runtime. beforeSend: workerBeforeSendHandler as CloudflareOptions["beforeSend"], + // Disable all default integrations (which include consoleIntegration capturing + // structured JSON logs as breadcrumbs) and add only what we need explicitly. + defaultIntegrations: false, integrations: [ requestDataIntegration({ include: { headers: false, cookies: false, data: false }, diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 098208ff..3ae0418a 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -48,6 +48,21 @@ describe("scrubUrl", () => { "https://example.com?code=[REDACTED]", ); }); + + it("strips client_secret= parameter", () => { + expect(scrubUrl("https://example.com?client_secret=supersecret")).toBe( + "https://example.com?client_secret=[FILTERED]", + ); + }); + + it("strips GitHub token prefixes (ghu_, ghp_, gho_, github_pat_)", () => { + expect(scrubUrl("Error: token ghu_abc123 exposed")).toBe( + "Error: token ghu_[FILTERED] exposed", + ); + expect(scrubUrl("token ghp_xyz789")).toBe("token ghp_[FILTERED]"); + expect(scrubUrl("token gho_def456")).toBe("token gho_[FILTERED]"); + expect(scrubUrl("token github_pat_abc123")).toBe("token github_pat_[FILTERED]"); + }); }); describe("beforeSendHandler", () => { @@ -138,6 +153,24 @@ describe("beforeSendHandler", () => { const result = beforeSendHandler(event as never); expect(result).toBeDefined(); }); + + it("scrubs sensitive tokens from exception message strings", () => { + const event = { + request: { url: "https://gh.gordoncode.dev" }, + exception: { + values: [ + { + value: "Fetch failed: client_secret=supersecret ghu_abc123", + }, + ], + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.exception!.values![0].value).not.toContain("supersecret"); + expect(result!.exception!.values![0].value).not.toContain("ghu_abc123"); + expect(result!.exception!.values![0].value).toContain("client_secret=[FILTERED]"); + expect(result!.exception!.values![0].value).toContain("ghu_[FILTERED]"); + }); }); describe("beforeBreadcrumbHandler", () => { diff --git a/tests/lib/worker-sentry.test.ts b/tests/lib/worker-sentry.test.ts index 45e07fbb..65a8ae8a 100644 --- a/tests/lib/worker-sentry.test.ts +++ b/tests/lib/worker-sentry.test.ts @@ -120,6 +120,18 @@ describe("workerBeforeSendHandler", () => { expect(frames[1].abs_path).toBe("https://example.com/lib.js"); }); + it("scrubs client_secret from request URL query parameter", () => { + const event = { + request: { + url: "https://github.com/login/oauth/access_token?client_id=abc&client_secret=secret123&code=xyz", + }, + }; + const result = workerBeforeSendHandler(event); + expect(result!.request!.url).not.toContain("secret123"); + expect(result!.request!.url).toContain("client_secret=[REDACTED]"); + expect(result!.request!.url).toContain("code=[REDACTED]"); + }); + it("scrubs client_secret pattern from exception message", () => { const event = { request: { url: "https://example.com" }, @@ -182,11 +194,17 @@ describe("getWorkerSentryOptions", () => { it("disables PII and tracing", () => { const opts = getWorkerSentryOptions({}); expect(opts.sendDefaultPii).toBe(false); - expect(opts.tracesSampleRate).toBe(0); + // tracesSampleRate is omitted so hasSpansEnabled() returns false (0 != null is true, undefined != null is false) + expect(opts.tracesSampleRate).toBeUndefined(); }); it("sets environment to production", () => { const opts = getWorkerSentryOptions({}); expect(opts.environment).toBe("production"); }); + + it("disables default integrations to suppress console breadcrumb capture", () => { + const opts = getWorkerSentryOptions({}); + expect(opts.defaultIntegrations).toBe(false); + }); }); diff --git a/wrangler.toml b/wrangler.toml index 7972e080..c2b9b609 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,7 +1,11 @@ name = "github-tracker" main = "src/worker/index.ts" compatibility_date = "2026-03-01" -compatibility_flags = ["global_fetch_strictly_public", "nodejs_als"] +compatibility_flags = [ + "global_fetch_strictly_public", + # nodejs_als: required by @sentry/cloudflare for AsyncLocalStorage (request context propagation) + "nodejs_als", +] workers_dev = false [assets] From 5b408236b2c74361f056120f24ccd5cefd1061d5 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 11:29:21 -0400 Subject: [PATCH 30/56] test(sentry): adds browser scrubbing and client_secret URL tests --- tests/lib/sentry.test.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 3ae0418a..70c2b76b 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -154,7 +154,40 @@ describe("beforeSendHandler", () => { expect(result).toBeDefined(); }); - it("scrubs sensitive tokens from exception message strings", () => { + it("scrubs OAuth params from exception message values", () => { + const event = { + request: { url: "https://gh.gordoncode.dev" }, + exception: { + values: [ + { + value: "Request failed with code=abc123&state=xyz in URL", + }, + ], + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.exception!.values![0].value).not.toContain("abc123"); + expect(result!.exception!.values![0].value).toContain("code=[REDACTED]"); + expect(result!.exception!.values![0].value).toContain("state=[REDACTED]"); + }); + + it("scrubs GitHub token prefixes from exception message values", () => { + const event = { + request: { url: "https://gh.gordoncode.dev" }, + exception: { + values: [ + { + value: "Token ghp_secrettoken123 was used in request", + }, + ], + }, + }; + const result = beforeSendHandler(event as never); + expect(result!.exception!.values![0].value).not.toContain("secrettoken123"); + expect(result!.exception!.values![0].value).toContain("ghp_[FILTERED]"); + }); + + it("scrubs client_secret and tokens from exception message values", () => { const event = { request: { url: "https://gh.gordoncode.dev" }, exception: { From dc85611d14cab93dc74ea5f2c88b99320fc99d87 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 11:32:57 -0400 Subject: [PATCH 31/56] refactor(sentry): simplifies post-review --- src/worker/sentry.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/worker/sentry.ts b/src/worker/sentry.ts index dc3c8fea..01294969 100644 --- a/src/worker/sentry.ts +++ b/src/worker/sentry.ts @@ -33,9 +33,7 @@ function scrubSensitive(s: string): string { .replace(/access_token=[^&\s"]+/g, "access_token=[REDACTED]") .replace(/client_secret=[^&\s"]+/g, "client_secret=[REDACTED]") .replace(/"client_secret":"[^"]+"/g, '"client_secret":"[REDACTED]"') - .replace(/\bghu_[A-Za-z0-9_]+/g, "ghu_[REDACTED]") - .replace(/\bghp_[A-Za-z0-9_]+/g, "ghp_[REDACTED]") - .replace(/\bgho_[A-Za-z0-9_]+/g, "gho_[REDACTED]"); + .replace(/\b(ghu_|ghp_|gho_)[A-Za-z0-9_]+/g, "$1[REDACTED]"); } export function workerBeforeSendHandler( From 6d08375861c62639df5f62ab90d11a2afa476562 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 12:37:28 -0400 Subject: [PATCH 32/56] fix(sentry): adds github_pat_ to worker scrubbing regex --- src/worker/sentry.ts | 2 +- tests/lib/worker-sentry.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/worker/sentry.ts b/src/worker/sentry.ts index 01294969..87225924 100644 --- a/src/worker/sentry.ts +++ b/src/worker/sentry.ts @@ -33,7 +33,7 @@ function scrubSensitive(s: string): string { .replace(/access_token=[^&\s"]+/g, "access_token=[REDACTED]") .replace(/client_secret=[^&\s"]+/g, "client_secret=[REDACTED]") .replace(/"client_secret":"[^"]+"/g, '"client_secret":"[REDACTED]"') - .replace(/\b(ghu_|ghp_|gho_)[A-Za-z0-9_]+/g, "$1[REDACTED]"); + .replace(/\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g, "$1[REDACTED]"); } export function workerBeforeSendHandler( diff --git a/tests/lib/worker-sentry.test.ts b/tests/lib/worker-sentry.test.ts index 65a8ae8a..83be9167 100644 --- a/tests/lib/worker-sentry.test.ts +++ b/tests/lib/worker-sentry.test.ts @@ -150,20 +150,21 @@ describe("workerBeforeSendHandler", () => { ); }); - it("scrubs GitHub token prefixes (ghu_, ghp_, gho_) from exception message", () => { + it("scrubs GitHub token prefixes (ghu_, ghp_, gho_, github_pat_) from exception message", () => { const event = { request: { url: "https://example.com" }, exception: { values: [ { - value: "Token ghu_abc123 or ghp_xyz789 or gho_def456 exposed", + value: + "Token ghu_abc123 or ghp_xyz789 or gho_def456 or github_pat_11ABCDEF exposed", }, ], }, }; const result = workerBeforeSendHandler(event); expect(result!.exception!.values![0].value).toBe( - "Token ghu_[REDACTED] or ghp_[REDACTED] or gho_[REDACTED] exposed" + "Token ghu_[REDACTED] or ghp_[REDACTED] or gho_[REDACTED] or github_pat_[REDACTED] exposed" ); }); From 61fc680e55cb529ee2a1be7987fba01ecf104d25 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 12:43:02 -0400 Subject: [PATCH 33/56] fix(sentry): harmonizes scrubbing regex and redaction labels --- src/app/lib/sentry.ts | 10 +++++----- tests/lib/sentry.test.ts | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 154c2c38..6063ffcf 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -4,11 +4,11 @@ import type { ErrorEvent, Breadcrumb } from "@sentry/solid"; /** Strip OAuth credentials and tokens 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]") - .replace(/client_secret=[^&\s"]+/gi, "client_secret=[FILTERED]") - .replace(/\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g, "$1[FILTERED]"); + .replace(/code=[^&\s"]+/g, "code=[REDACTED]") + .replace(/state=[^&\s"]+/g, "state=[REDACTED]") + .replace(/access_token=[^&\s"]+/g, "access_token=[REDACTED]") + .replace(/client_secret=[^&\s"]+/gi, "client_secret=[REDACTED]") + .replace(/\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g, "$1[REDACTED]"); } /** Allowed console breadcrumb prefixes — drop everything else. */ diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 70c2b76b..d245524c 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -51,17 +51,17 @@ describe("scrubUrl", () => { it("strips client_secret= parameter", () => { expect(scrubUrl("https://example.com?client_secret=supersecret")).toBe( - "https://example.com?client_secret=[FILTERED]", + "https://example.com?client_secret=[REDACTED]", ); }); it("strips GitHub token prefixes (ghu_, ghp_, gho_, github_pat_)", () => { expect(scrubUrl("Error: token ghu_abc123 exposed")).toBe( - "Error: token ghu_[FILTERED] exposed", + "Error: token ghu_[REDACTED] exposed", ); - expect(scrubUrl("token ghp_xyz789")).toBe("token ghp_[FILTERED]"); - expect(scrubUrl("token gho_def456")).toBe("token gho_[FILTERED]"); - expect(scrubUrl("token github_pat_abc123")).toBe("token github_pat_[FILTERED]"); + expect(scrubUrl("token ghp_xyz789")).toBe("token ghp_[REDACTED]"); + expect(scrubUrl("token gho_def456")).toBe("token gho_[REDACTED]"); + expect(scrubUrl("token github_pat_abc123")).toBe("token github_pat_[REDACTED]"); }); }); @@ -184,7 +184,7 @@ describe("beforeSendHandler", () => { }; const result = beforeSendHandler(event as never); expect(result!.exception!.values![0].value).not.toContain("secrettoken123"); - expect(result!.exception!.values![0].value).toContain("ghp_[FILTERED]"); + expect(result!.exception!.values![0].value).toContain("ghp_[REDACTED]"); }); it("scrubs client_secret and tokens from exception message values", () => { @@ -201,8 +201,8 @@ describe("beforeSendHandler", () => { const result = beforeSendHandler(event as never); expect(result!.exception!.values![0].value).not.toContain("supersecret"); expect(result!.exception!.values![0].value).not.toContain("ghu_abc123"); - expect(result!.exception!.values![0].value).toContain("client_secret=[FILTERED]"); - expect(result!.exception!.values![0].value).toContain("ghu_[FILTERED]"); + expect(result!.exception!.values![0].value).toContain("client_secret=[REDACTED]"); + expect(result!.exception!.values![0].value).toContain("ghu_[REDACTED]"); }); }); From b1c905087032163dad9b65ff4e85368be5a21040 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 12:59:55 -0400 Subject: [PATCH 34/56] fix(sentry): selective integrations, case-insensitive scrub --- DEPLOY.md | 2 ++ src/worker/sentry.ts | 11 ++++++----- tests/lib/worker-sentry.test.ts | 21 ++++++++++++++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index c42eaebf..0bd74e4d 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -152,6 +152,8 @@ The self-signed certificate from `--local-protocol https` must be accepted in th The `global_fetch_strictly_public` compatibility flag (which blocks Worker subrequests to private/internal IPs) has **no effect** in local `wrangler dev` — workerd ignores it. No local dev workaround is needed for this flag. +The `nodejs_als` compatibility flag is required by `@sentry/cloudflare` for `AsyncLocalStorage` (request context propagation). It is declared in `wrangler.toml` and is active in both production and local dev. + ## Deploy Manually ```sh diff --git a/src/worker/sentry.ts b/src/worker/sentry.ts index 87225924..69a5eaae 100644 --- a/src/worker/sentry.ts +++ b/src/worker/sentry.ts @@ -31,7 +31,7 @@ function scrubSensitive(s: string): string { .replace(/code=[^&\s"]+/g, "code=[REDACTED]") .replace(/state=[^&\s"]+/g, "state=[REDACTED]") .replace(/access_token=[^&\s"]+/g, "access_token=[REDACTED]") - .replace(/client_secret=[^&\s"]+/g, "client_secret=[REDACTED]") + .replace(/client_secret=[^&\s"]+/gi, "client_secret=[REDACTED]") .replace(/"client_secret":"[^"]+"/g, '"client_secret":"[REDACTED]"') .replace(/\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g, "$1[REDACTED]"); } @@ -88,10 +88,11 @@ export function getWorkerSentryOptions(env: SentryEnv): CloudflareOptions { // Cast: workerBeforeSendHandler uses a minimal local interface for testability // but is fully compatible with ErrorEvent at runtime. beforeSend: workerBeforeSendHandler as CloudflareOptions["beforeSend"], - // Disable all default integrations (which include consoleIntegration capturing - // structured JSON logs as breadcrumbs) and add only what we need explicitly. - defaultIntegrations: false, - integrations: [ + // Filter out Console integration (captures structured JSON logs as noise + // breadcrumbs) but keep LinkedErrors, Dedupe, and other useful defaults. + // Replace RequestData with our hardened config (headers/cookies/data suppressed). + integrations: (defaults) => [ + ...defaults.filter((i) => i.name !== "Console" && i.name !== "RequestData"), requestDataIntegration({ include: { headers: false, cookies: false, data: false }, }), diff --git a/tests/lib/worker-sentry.test.ts b/tests/lib/worker-sentry.test.ts index 83be9167..3605a1df 100644 --- a/tests/lib/worker-sentry.test.ts +++ b/tests/lib/worker-sentry.test.ts @@ -180,7 +180,10 @@ describe("getWorkerSentryOptions", () => { it("returns correct requestDataIntegration config", async () => { const { requestDataIntegration } = await import("@sentry/cloudflare"); const env = { SENTRY_DSN: "https://key@sentry.io/123" }; - getWorkerSentryOptions(env); + const opts = getWorkerSentryOptions(env); + // integrations is now a filter function — invoke it to trigger requestDataIntegration call + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (opts.integrations as (defaults: any[]) => any[])([]); expect(requestDataIntegration).toHaveBeenCalledWith({ include: { headers: false, cookies: false, data: false }, }); @@ -204,8 +207,20 @@ describe("getWorkerSentryOptions", () => { expect(opts.environment).toBe("production"); }); - it("disables default integrations to suppress console breadcrumb capture", () => { + it("uses integration filter function to remove Console and replace RequestData", () => { const opts = getWorkerSentryOptions({}); - expect(opts.defaultIntegrations).toBe(false); + expect(typeof opts.integrations).toBe("function"); + // Simulate the SDK passing default integrations + const fakeConsole = { name: "Console" }; + const fakeLinkedErrors = { name: "LinkedErrors" }; + const fakeRequestData = { name: "RequestData" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filterFn = opts.integrations as (defaults: any[]) => any[]; + const filtered = filterFn([fakeConsole, fakeLinkedErrors, fakeRequestData]); + // Console and default RequestData should be removed + expect(filtered.find((i: { name: string }) => i.name === "Console")).toBeUndefined(); + expect(filtered.find((i: { name: string }) => i.name === "RequestData")).toBeUndefined(); + // LinkedErrors should be preserved + expect(filtered.find((i: { name: string }) => i.name === "LinkedErrors")).toBe(fakeLinkedErrors); }); }); From 0e9f43b0b71bce7b1425da25d5fd215f866145e2 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 13:11:32 -0400 Subject: [PATCH 35/56] refactor(worker): consolidates dual @sentry/cloudflare imports --- src/worker/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 62d8c563..5907fa27 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,4 +1,3 @@ -import { withSentry } from "@sentry/cloudflare"; import * as Sentry from "@sentry/cloudflare"; import { CryptoEnv, deriveKey, sealToken, SEAL_SALT } from "./crypto"; import { SessionEnv, ensureSession } from "./session"; @@ -780,7 +779,7 @@ async function handleTokenExchange( }); } -export default withSentry( +export default Sentry.withSentry( (env: Env) => getWorkerSentryOptions(env), { async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { From 1a758c5c8ca8637507e9a784fe5206c4f704dfa8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 13:14:21 -0400 Subject: [PATCH 36/56] fix(sentry): adds DSN cross-reference and ExecutionContext comments --- src/app/lib/sentry.ts | 2 ++ src/worker/index.ts | 2 ++ wrangler.toml | 1 + 3 files changed, 5 insertions(+) diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 6063ffcf..fbac88c7 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -26,6 +26,8 @@ const ALLOWED_CONSOLE_PREFIXES = [ "[notifications]", ]; +// KEEP IN SYNC with SENTRY_DSN in wrangler.toml [vars] — the worker tunnel +// validates envelope DSN against env.SENTRY_DSN and rejects mismatches (403). const SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240"; export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { diff --git a/src/worker/index.ts b/src/worker/index.ts index 5907fa27..17a6b206 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -5,6 +5,8 @@ import { TurnstileEnv, verifyTurnstile, extractTurnstileToken } from "./turnstil import { validateProxyRequest, validateOrigin } from "./validation"; import { getWorkerSentryOptions } from "./sentry"; +// Local interface — project does not install @cloudflare/workers-types. +// Matches the real Cloudflare ExecutionContext (waitUntil + passThroughOnException). interface ExecutionContext { waitUntil(promise: Promise): void; passThroughOnException(): void; diff --git a/wrangler.toml b/wrangler.toml index c2b9b609..8666f038 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -22,6 +22,7 @@ custom_domain = true [vars] # SENTRY_DSN is used by both the Sentry tunnel endpoint (DSN validation) and the # @sentry/cloudflare SDK for worker-side error capture. No additional secrets needed. +# KEEP IN SYNC with SENTRY_DSN in src/app/lib/sentry.ts — mismatch causes 403 on tunnel. SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240" [[ratelimits]] From 0e0d4aaebe9875f0be6ee30889ef1d37b72a8a4b Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 13:19:41 -0400 Subject: [PATCH 37/56] fix(worker): extends CSP scrubbing with token patterns --- src/worker/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 17a6b206..7d2c9b2e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -471,11 +471,14 @@ async function handleSentryTunnel( // 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; +const CSP_OAUTH_PARAMS_RE = /([?&])(code|state|access_token|client_secret)=[^&\s]*/gi; +const CSP_TOKEN_PREFIX_RE = /\b(ghu_|ghp_|gho_|github_pat_)[A-Za-z0-9_]+/g; function scrubReportUrl(url: unknown): string | undefined { if (typeof url !== "string") return undefined; - return url.replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]"); + return url + .replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]") + .replace(CSP_TOKEN_PREFIX_RE, "$1[REDACTED]"); } const CSP_FIELD_MAX_LENGTH = 2048; From 3713159d431e1b3276480823fd729fcd44921db0 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 13:31:26 -0400 Subject: [PATCH 38/56] fix(worker): requires Origin on CSP report endpoint --- src/worker/index.ts | 4 +++- tests/worker/csp-report.test.ts | 21 ++++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 7d2c9b2e..ff5ee6fa 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -522,8 +522,10 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 429, headers: { "Retry-After": "60", ...SECURITY_HEADERS } }); } + // Same-origin CSP reports (report-uri /api/csp-report) always include Origin. + // Reject missing Origin — only non-browser clients (curl, scripts) omit it. const origin = request.headers.get("Origin"); - if (origin !== null && origin !== env.ALLOWED_ORIGIN) { + if (origin !== env.ALLOWED_ORIGIN) { log("warn", "csp_report_origin_rejected", { origin }, request); return new Response(null, { status: 403, headers: SECURITY_HEADERS }); } diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 5a3750ca..7998336d 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -32,8 +32,11 @@ function makeCspRequest( // Unique IP per request to avoid hitting the in-memory rate limiter across tests "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, }; - if (options.origin !== undefined && options.origin !== null) { - headers["Origin"] = options.origin; + // Default to ALLOWED_ORIGIN (same-origin CSP reports always include it). + // Pass { origin: null } to explicitly test missing Origin. + const effectiveOrigin = options.origin === undefined ? ALLOWED_ORIGIN : options.origin; + if (effectiveOrigin !== null) { + headers["Origin"] = effectiveOrigin; } return new Request("https://gh.gordoncode.dev/api/csp-report", { method, @@ -389,12 +392,11 @@ describe("Worker CSP report endpoint", () => { expect(resp.status).toBe(403); }); - it("allows requests with missing Origin (soft check — browser CSP reports may lack it)", async () => { + it("rejects requests with missing Origin (same-origin CSP reports always include it)", async () => { const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); - // No origin option means no Origin header in makeCspRequest - const req = makeCspRequest(body, "application/csp-report", "POST"); + const req = makeCspRequest(body, "application/csp-report", "POST", { origin: null }); const resp = await worker.fetch(req, makeEnv()); - expect(resp.status).toBe(204); + expect(resp.status).toBe(403); }); it("rejects requests with Origin: null (string literal from sandboxed iframes) with 403", async () => { @@ -444,13 +446,13 @@ describe("Worker CSP report endpoint", () => { for (let i = 0; i < 15; i++) { await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { method: "POST", - headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp, "Origin": ALLOWED_ORIGIN }, body, }), env); } const limited = await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { method: "POST", - headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp, "Origin": ALLOWED_ORIGIN }, body, }), env); expect(limited.status).toBe(429); @@ -458,7 +460,7 @@ describe("Worker CSP report endpoint", () => { // Different IP should still succeed const otherResp = await worker.fetch(new Request("https://gh.gordoncode.dev/api/csp-report", { method: "POST", - headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": "10.3.99.3" }, + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": "10.3.99.3", "Origin": ALLOWED_ORIGIN }, body, }), env); expect(otherResp.status).toBe(204); @@ -472,6 +474,7 @@ describe("Worker CSP report endpoint", () => { headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, "Content-Length": String(64 * 1024 + 1), }, body: "x", From a17d6185dcb7b14dc4daec7831a4ad74daa7946a Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:06:34 -0400 Subject: [PATCH 39/56] fix(sentry): externalize DSN to VITE_SENTRY_DSN env var - Remove hardcoded SENTRY_DSN constant; read from import.meta.env.VITE_SENTRY_DSN at runtime - Replace hardcoded allowUrls regex with window.location.origin (string prefix match) - Add VITE_SENTRY_DSN and VITE_TURNSTILE_SITE_KEY to deploy.yml build env - Document VITE_SENTRY_DSN in .env.example - Add initSentry tests covering no-op (empty DSN), DSN forwarding, and allowUrls --- .env.example | 5 +++ .github/workflows/deploy.yml | 2 ++ src/app/lib/sentry.ts | 11 +++---- tests/lib/sentry.test.ts | 61 +++++++++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 897c8b40..eb5c8139 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,8 @@ GITHUB_TOKEN=your_github_token_here # Get this from the Cloudflare Turnstile dashboard. # Note: TURNSTILE_SECRET_KEY is a Worker secret (goes in .dev.vars, not .env). VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key-from-cf-dashboard + +# ── Sentry (optional) ───────────────────────────────────────────────────────── +# Sentry DSN for error reporting. Leave empty to disable Sentry. +# Get this from your Sentry project's Client Keys (DSN) settings. +# VITE_SENTRY_DSN=https://your-public-key@o12345.ingest.us.sentry.io/your-project-id diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69d2c0be..c34f2e38 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,8 @@ jobs: - run: pnpm run build env: VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_TURNSTILE_SITE_KEY: ${{ vars.VITE_TURNSTILE_SITE_KEY }} - uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index fbac88c7..ad3ddd48 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -26,10 +26,6 @@ const ALLOWED_CONSOLE_PREFIXES = [ "[notifications]", ]; -// KEEP IN SYNC with SENTRY_DSN in wrangler.toml [vars] — the worker tunnel -// validates envelope DSN against env.SENTRY_DSN and rejects mismatches (403). -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) { @@ -95,10 +91,11 @@ export function beforeBreadcrumbHandler( } export function initSentry(): void { - if (import.meta.env.DEV || !SENTRY_DSN) return; + const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined; + if (import.meta.env.DEV || !dsn) return; Sentry.init({ - dsn: SENTRY_DSN, + dsn, tunnel: "/api/error-reporting", environment: import.meta.env.MODE, @@ -109,7 +106,7 @@ export function initSentry(): void { profilesSampleRate: 0, // ── Only capture errors from our own code ─────────────────── - allowUrls: [/^https:\/\/gh\.gordoncode\.dev/], + allowUrls: [window.location.origin], // ── Scrub sensitive data before it leaves the browser ──────── beforeSend: beforeSendHandler, diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index d245524c..020cd866 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { scrubUrl, beforeSendHandler, beforeBreadcrumbHandler, + initSentry, } from "../../src/app/lib/sentry"; +vi.mock("@sentry/solid", () => ({ + init: vi.fn(), +})); + describe("scrubUrl", () => { it("strips code= parameter", () => { expect(scrubUrl("https://example.com/cb?code=abc123&state=xyz")).toBe( @@ -283,3 +288,57 @@ describe("beforeBreadcrumbHandler", () => { expect(beforeBreadcrumbHandler(breadcrumb as never)).toBe(breadcrumb); }); }); + +describe("initSentry", () => { + // Import the mock so we can inspect calls + let mockInit: ReturnType; + + beforeEach(async () => { + const sentry = await import("@sentry/solid"); + mockInit = sentry.init as ReturnType; + mockInit.mockClear(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("is a no-op when VITE_SENTRY_DSN is undefined", () => { + vi.stubEnv("VITE_SENTRY_DSN", ""); + initSentry(); + expect(mockInit).not.toHaveBeenCalled(); + }); + + it("is a no-op when VITE_SENTRY_DSN is empty string", () => { + vi.stubEnv("VITE_SENTRY_DSN", ""); + initSentry(); + expect(mockInit).not.toHaveBeenCalled(); + }); + + it("calls Sentry.init with correct DSN when VITE_SENTRY_DSN is set", () => { + vi.stubEnv("DEV", false); + vi.stubEnv("VITE_SENTRY_DSN", "https://test-key@o1.ingest.us.sentry.io/1"); + initSentry(); + expect(mockInit).toHaveBeenCalledOnce(); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: "https://test-key@o1.ingest.us.sentry.io/1", + }), + ); + }); + + it("sets allowUrls to window.location.origin", () => { + vi.stubEnv("DEV", false); + vi.stubEnv("VITE_SENTRY_DSN", "https://test-key@o1.ingest.us.sentry.io/1"); + vi.stubGlobal("location", { ...window.location, origin: "https://test.example.com" }); + initSentry(); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ + allowUrls: ["https://test.example.com"], + }), + ); + }); +}); From 1be3e1e331c0c3fa9933f951c16705ebbb7fa51e Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:08:40 -0400 Subject: [PATCH 40/56] fix(mcp): removes hardcoded domain from relay allowed origins - Removes 'https://gh.gordoncode.dev' from ALLOWED_ORIGINS_DEFAULT - Adds startup warning when MCP_RELAY_ALLOWED_ORIGINS is not set - Documents MCP_RELAY_ALLOWED_ORIGINS in mcp/README.md --- mcp/README.md | 1 + mcp/src/ws-relay.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 44b8cec2..31d4eb31 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -18,6 +18,7 @@ npm install -g github-tracker-mcp |----------|----------|---------|-------------| | `GITHUB_TOKEN` | No | — | Classic PAT with `repo` and `read:org` scopes (recommended), or fine-grained PAT with Actions (read), Contents (read), Issues (read), and Pull requests (read) permissions. Fine-grained PATs skip scope validation at startup. | | `MCP_WS_PORT` | No | `9876` | WebSocket relay port for receiving live data from the dashboard SPA. | +| `MCP_RELAY_ALLOWED_ORIGINS` | No | — | Comma-separated additional origins for WebSocket connections (e.g., `https://your-domain.example.com`). Localhost origins are always allowed. | `GITHUB_TOKEN` is required for standalone (direct API) mode. In relay mode the server receives data from the dashboard and works without a token. If you set `GITHUB_TOKEN` alongside the relay, the server uses it as a fallback when the relay disconnects. diff --git a/mcp/src/ws-relay.ts b/mcp/src/ws-relay.ts index 049a68ff..c0b01f16 100644 --- a/mcp/src/ws-relay.ts +++ b/mcp/src/ws-relay.ts @@ -44,7 +44,6 @@ const ALLOWED_ORIGINS_DEFAULT = new Set([ "https://localhost", "http://127.0.0.1", "https://127.0.0.1", - "https://gh.gordoncode.dev", ]); function buildAllowedOrigins(): Set { @@ -61,6 +60,11 @@ function buildAllowedOrigins(): Set { // Computed once at module scope — origins don't change at runtime const ALLOWED_ORIGINS = buildAllowedOrigins(); +// Warn if only localhost origins are configured — production domains need MCP_RELAY_ALLOWED_ORIGINS +if (!process.env.MCP_RELAY_ALLOWED_ORIGINS) { + console.error("[mcp/ws] Warning: No MCP_RELAY_ALLOWED_ORIGINS set — only localhost connections allowed. Set this to your production domain to allow WebSocket relay from the deployed SPA."); +} + function isOriginAllowed(origin: string | undefined): boolean { // Non-browser clients (e.g. CLI tools) do not send Origin — allow them. if (origin === undefined) return true; From 1856c290363fc2427d40980577de74ecb38bb64e Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:11:41 -0400 Subject: [PATCH 41/56] fix(deploy): makes WAF test domain configurable, adds deploy comments - Accepts optional base_url argument in waf-smoke-test.sh - Gates WAF smoke test in deploy.yml on DEPLOY_DOMAIN variable - Adds DEPLOY comments to wrangler.toml for routes and SENTRY_DSN vars - Moves SENTRY_DSN from wrangler.toml [vars] to wrangler secret (commented template) --- .github/workflows/deploy.yml | 3 ++- scripts/waf-smoke-test.sh | 5 +++-- wrangler.toml | 9 ++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c34f2e38..4dc360e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,8 @@ jobs: - name: Verify CSP inline script hash run: bash scripts/verify-csp-hash.sh - name: WAF smoke tests - run: pnpm test:waf + if: vars.DEPLOY_DOMAIN != '' + run: pnpm test:waf https://${{ vars.DEPLOY_DOMAIN }} - run: pnpm run build env: VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh index 1524b50f..d88d68a8 100755 --- a/scripts/waf-smoke-test.sh +++ b/scripts/waf-smoke-test.sh @@ -2,7 +2,8 @@ # WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev # Requires: GNU parallel (brew install parallel / apt install parallel) # -# Usage: pnpm test:waf +# Usage: pnpm test:waf [base_url] +# e.g. pnpm test:waf https://my-tracker.example.com # # Rules validated: # 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/* @@ -16,7 +17,7 @@ if ! command -v parallel &>/dev/null; then exit 1 fi -BASE="https://gh.gordoncode.dev" +BASE="${1:-https://gh.gordoncode.dev}" # --- Test runner (exported for GNU parallel) --- run_test() { diff --git a/wrangler.toml b/wrangler.toml index 8666f038..6a93eb25 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -15,15 +15,14 @@ directory = "public" binding = "ASSETS" not_found_handling = "single-page-application" +# DEPLOY: Change the pattern below to your custom domain. [[routes]] pattern = "gh.gordoncode.dev" custom_domain = true -[vars] -# SENTRY_DSN is used by both the Sentry tunnel endpoint (DSN validation) and the -# @sentry/cloudflare SDK for worker-side error capture. No additional secrets needed. -# KEEP IN SYNC with SENTRY_DSN in src/app/lib/sentry.ts — mismatch causes 403 on tunnel. -SENTRY_DSN = "https://4dc4335a9746201c02ff2107c0d20f73@o284235.ingest.us.sentry.io/4511122822922240" +# DEPLOY: Uncomment and set your Sentry DSN, or leave commented to disable error reporting. +# [vars] +# SENTRY_DSN = "https://your-public-key@o12345.ingest.us.sentry.io/your-project-id" [[ratelimits]] name = "PROXY_RATE_LIMITER" From 4602c47249fcb677db74c648b62e81288cad7c9b Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:14:54 -0400 Subject: [PATCH 42/56] feat(deploy): adds deploy validation script - Creates scripts/validate-deploy.sh with CI and local modes - CI mode checks required env vars (fails on VITE_GITHUB_CLIENT_ID, CF credentials; warns on optional) - Local mode checks CF Worker secrets via wrangler secret list - Adds validate:deploy script to package.json - Adds validation step to deploy.yml before build (never echoes secret values) --- .github/workflows/deploy.yml | 8 ++++++++ package.json | 3 ++- scripts/validate-deploy.sh | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100755 scripts/validate-deploy.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4dc360e6..41cf76bd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,6 +23,14 @@ jobs: - name: WAF smoke tests if: vars.DEPLOY_DOMAIN != '' run: pnpm test:waf https://${{ vars.DEPLOY_DOMAIN }} + - name: Validate deploy configuration + run: pnpm validate:deploy --ci + env: + VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_TURNSTILE_SITE_KEY: ${{ vars.VITE_TURNSTILE_SITE_KEY }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: pnpm run build env: VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} diff --git a/package.json b/package.json index a9ea82e2..705368be 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "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", "screenshot": "pnpm exec playwright test --config playwright.config.screenshot.ts", - "mcp:serve": "pnpm --filter github-tracker-mcp dev" + "mcp:serve": "pnpm --filter github-tracker-mcp dev", + "validate:deploy": "bash scripts/validate-deploy.sh" }, "dependencies": { "@kobalte/core": "0.13.11", diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh new file mode 100755 index 00000000..2663c8d8 --- /dev/null +++ b/scripts/validate-deploy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Usage: pnpm validate:deploy [--ci] +# Local: checks CF Worker secrets via wrangler secret list +# CI: checks build-time env vars and deploy credentials +# SECURITY: This script must NEVER echo, log, or display secret values. +set -euo pipefail +CI_MODE=false +[[ "${1:-}" == "--ci" ]] && CI_MODE=true + +ERRORS=0 +warn() { printf '[WARN] %s\n' "$1" >&2; } +fail() { printf '[FAIL] %s\n' "$1" >&2; ERRORS=$((ERRORS+1)); } + +if $CI_MODE; then + [[ -z "${VITE_GITHUB_CLIENT_ID:-}" ]] && fail "VITE_GITHUB_CLIENT_ID not set (add as GitHub Actions variable)" + [[ -z "${VITE_SENTRY_DSN:-}" ]] && warn "VITE_SENTRY_DSN not set — Sentry disabled in this build" + [[ -z "${VITE_TURNSTILE_SITE_KEY:-}" ]] && warn "VITE_TURNSTILE_SITE_KEY not set — Turnstile disabled" + [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]] && fail "CLOUDFLARE_API_TOKEN not set (add as GitHub Actions secret)" + [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]] && fail "CLOUDFLARE_ACCOUNT_ID not set (add as GitHub Actions secret)" +else + if ! command -v wrangler &>/dev/null; then + fail "wrangler CLI not found — install with: pnpm add -g wrangler" + else + if ! SECRETS=$(wrangler secret list 2>&1); then + fail "wrangler secret list failed — run: wrangler login" + else + for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY; do + echo "$SECRETS" | grep -q "\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" + done + echo "$SECRETS" | grep -q '"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry + CSP tunnels return 404" + fi + fi +fi + +if [[ $ERRORS -eq 0 ]]; then + printf '[OK] All required deploy configuration is in place.\n' + exit 0 +fi +printf '[ERROR] %d required item(s) missing — see above.\n' "$ERRORS" >&2 +exit 1 From 8515913eb8b8b5c1e96a7f9b0650fa449bec455b Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:20:12 -0400 Subject: [PATCH 43/56] docs(deploy): adds fork deployment checklist - Adds Fork Deployment Checklist section before GitHub Actions section - Covers required steps, optional config, and static-only deployment guide - Updates OAuth App URLs to use YOUR-DOMAIN placeholder - Updates ALLOWED_ORIGIN example to use YOUR-DOMAIN placeholder - Updates WAF rule expression to use YOUR-DOMAIN placeholder - Updates SENTRY_DSN docs to reflect Worker secret (not wrangler.toml [vars]) --- DEPLOY.md | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 0bd74e4d..8b6b2da3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,5 +1,86 @@ # Deployment Guide +## Fork Deployment Checklist + +If you're deploying your own instance of GitHub Tracker, update these values: + +### Required (deployment won't work without these) + +1. **Create a GitHub OAuth App** — [Settings → Developer settings → OAuth Apps](https://github.com/settings/developers) + - Set the callback URL to `https://YOUR-DOMAIN/oauth/callback` +2. **Update `wrangler.toml`** — Change `pattern = "gh.gordoncode.dev"` to your domain +3. **Set GitHub Actions secrets and variables** — See sections below +4. **Set Cloudflare Worker secrets** — See "Cloudflare Worker Secrets" section below. **Critical:** `ALLOWED_ORIGIN` must exactly match your deployment URL (e.g., `https://your-domain.example.com`). An incorrect value causes all API requests to fail with CORS errors. + +### Verify configuration + +Run `pnpm validate:deploy` locally to check that all required Cloudflare Worker secrets +are set. In CI, the deploy workflow runs `pnpm validate:deploy --ci` automatically before +building. + +### Optional + +5. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira/GitLab token sealing feature. +6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. +7. **MCP relay** — Set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server +8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. +9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain +10. **Security contact** — Update the email and scope domain in `SECURITY.md` +11. **README** — Update the "Live demo" URL in `README.md` +12. **User Guide** — Update the domain reference in `docs/USER_GUIDE.md` +13. **App footer links** — Update "Source" and "Guide" URLs in `src/app/components/dashboard/DashboardPage.tsx` and `src/app/components/settings/SettingsPage.tsx` if you want them to point to your fork +14. **Contributing guide** — Update the clone URL and PR target in `CONTRIBUTING.md` + +### Static-only deployment (no Cloudflare Worker) + +GitHub Tracker can run as a pure static site without the Cloudflare Worker backend. +Use a Personal Access Token (PAT) instead of OAuth — the PAT flow validates directly +against `api.github.com` with no server-side component. + +**Host the `dist/` build output on any static platform:** GitHub Pages, Netlify, Vercel, +S3 + CloudFront, or any CDN that serves SPAs with `index.html` fallback for client-side +routing. + +**What works without a backend:** +- All dashboard features (Issues, PRs, Actions tabs) +- PAT authentication (classic `ghp_` or fine-grained `github_pat_`) +- All GitHub API calls (GraphQL + REST, direct to `api.github.com`) +- Full poll + hot poll refresh cycles +- Desktop notifications +- Multi-user tracking, upstream repo discovery, monitor-all mode +- Repo pinning/reordering, themes, ignore system +- IndexedDB caching + ETag optimization +- MCP server (separate Node.js process, independent of Worker) + +**What does NOT work without a backend:** +- **OAuth login** — requires server-side `client_secret` exchange. Use a PAT instead. + The "Sign in with GitHub" button will still appear on the login page but will fail + if no `VITE_GITHUB_CLIENT_ID` is configured. +- **Sentry error reporting** — do NOT set `VITE_SENTRY_DSN` on static-only deploys. + The Sentry SDK is configured with `tunnel: "/api/error-reporting"`, which doesn't + exist on a static host. Setting a DSN causes the SDK to silently lose all error + reports via 404s. Leave `VITE_SENTRY_DSN` empty to cleanly disable Sentry. +- **CSP violation reporting** — reports silently dropped (no user impact). The + `report-uri /api/csp-report` directive in `public/_headers` will produce harmless + 404 console errors on static hosts. Optionally remove the `report-uri` and + `report-to` directives if the noise is unwanted. +- **Jira/GitLab token sealing** (planned) — requires server-side encryption + +**Security note:** The `public/_headers` file sets Content-Security-Policy and other +security headers. Ensure your static host serves these headers — Cloudflare Pages, +Netlify, and Vercel support `_headers` files natively. Other hosts may need manual +header configuration. + +**Build for static deployment:** +```sh +pnpm install +pnpm run build # Output in dist/ +# Upload dist/ to your static host +``` +No `VITE_GITHUB_CLIENT_ID` or `VITE_TURNSTILE_SITE_KEY` is needed for PAT-only +deployments — leave them empty. OAuth login won't work without a client ID (use +PAT instead), and Turnstile is only used by the planned Jira/GitLab integration. + ## GitHub Actions Secrets and Variables ### Secrets (GitHub repo → Settings → Secrets and variables → Actions → Secrets) @@ -29,8 +110,8 @@ 1. Go to GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App** 2. Fill in the details: - **Application name**: your app name (e.g. `gh-tracker-yourname`) - - **Homepage URL**: `https://gh.gordoncode.dev` - - **Authorization callback URL**: `https://gh.gordoncode.dev/oauth/callback` + - **Homepage URL**: `https://YOUR-DOMAIN` (e.g. `https://gh.gordoncode.dev`) + - **Authorization callback URL**: `https://YOUR-DOMAIN/oauth/callback` 3. Click **Register application** 4. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID` 5. Click **Generate a new client secret** and save it for the Worker secrets below @@ -67,7 +148,7 @@ wrangler secret put ALLOWED_ORIGIN - `GITHUB_CLIENT_ID`: same value as `VITE_GITHUB_CLIENT_ID` - `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub OAuth App -- `ALLOWED_ORIGIN`: `https://gh.gordoncode.dev` +- `ALLOWED_ORIGIN`: `https://YOUR-DOMAIN` (e.g. `https://gh.gordoncode.dev`) ## Worker API Endpoints @@ -187,7 +268,7 @@ Configure these rules in the Cloudflare dashboard under **Security → WAF**. **Expression:** ``` (http.request.uri.path starts_with "/api/") and -not (any(http.request.headers["origin"][*] in {"https://gh.gordoncode.dev"})) and +not (any(http.request.headers["origin"][*] in {"https://YOUR-DOMAIN"})) and not (http.request.uri.path eq "/api/csp-report") and not (http.request.uri.path eq "/api/error-reporting") ``` @@ -241,7 +322,7 @@ wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard ``` - `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`. -- `SENTRY_DSN` (configured in `wrangler.toml` `[vars]`, not a secret): used by both the Sentry tunnel endpoint for DSN validation and the `@sentry/cloudflare` SDK for direct worker-side error capture. Sentry DSNs are public keys — they authorize sending events, not reading them. +- `SENTRY_DSN` (Worker secret, set via `wrangler secret put SENTRY_DSN`): used by both the Sentry tunnel endpoint for DSN validation and the `@sentry/cloudflare` SDK for direct worker-side error capture. Sentry DSNs are public keys — they authorize sending events, not reading them. Must match the `VITE_SENTRY_DSN` build-time env var; a mismatch causes tunnel requests to return 403. - `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. - `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key). - `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key). From 1ccaede97a3dc390c7ac1c9d6268c9b17ae36bb0 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 14:38:37 -0400 Subject: [PATCH 44/56] fix: address review findings from Phase 4 --- .github/workflows/deploy.yml | 2 +- mcp/src/ws-relay.ts | 2 +- scripts/validate-deploy.sh | 6 +++--- scripts/waf-smoke-test.sh | 2 +- tests/lib/sentry.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 41cf76bd..22edac83 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: run: bash scripts/verify-csp-hash.sh - name: WAF smoke tests if: vars.DEPLOY_DOMAIN != '' - run: pnpm test:waf https://${{ vars.DEPLOY_DOMAIN }} + run: pnpm test:waf "https://${{ vars.DEPLOY_DOMAIN }}" - name: Validate deploy configuration run: pnpm validate:deploy --ci env: diff --git a/mcp/src/ws-relay.ts b/mcp/src/ws-relay.ts index c0b01f16..bda3a222 100644 --- a/mcp/src/ws-relay.ts +++ b/mcp/src/ws-relay.ts @@ -62,7 +62,7 @@ const ALLOWED_ORIGINS = buildAllowedOrigins(); // Warn if only localhost origins are configured — production domains need MCP_RELAY_ALLOWED_ORIGINS if (!process.env.MCP_RELAY_ALLOWED_ORIGINS) { - console.error("[mcp/ws] Warning: No MCP_RELAY_ALLOWED_ORIGINS set — only localhost connections allowed. Set this to your production domain to allow WebSocket relay from the deployed SPA."); + console.warn("[mcp/ws] Warning: No MCP_RELAY_ALLOWED_ORIGINS set — only localhost connections allowed. Set this to your production domain to allow WebSocket relay from the deployed SPA."); } function isOriginAllowed(origin: string | undefined): boolean { diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index 2663c8d8..12288fc1 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -21,13 +21,13 @@ else if ! command -v wrangler &>/dev/null; then fail "wrangler CLI not found — install with: pnpm add -g wrangler" else - if ! SECRETS=$(wrangler secret list 2>&1); then + if ! SECRETS=$(wrangler secret list --json 2>&1); then fail "wrangler secret list failed — run: wrangler login" else for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY; do - echo "$SECRETS" | grep -q "\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" + echo "$SECRETS" | grep -q "\"name\":\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" done - echo "$SECRETS" | grep -q '"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry + CSP tunnels return 404" + echo "$SECRETS" | grep -q '"name":"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" fi fi fi diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh index d88d68a8..001b3bfa 100755 --- a/scripts/waf-smoke-test.sh +++ b/scripts/waf-smoke-test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# WAF Smoke Tests — validates Cloudflare WAF rules for gh.gordoncode.dev +# WAF Smoke Tests — validates Cloudflare WAF rules for a deployment domain # Requires: GNU parallel (brew install parallel / apt install parallel) # # Usage: pnpm test:waf [base_url] diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 020cd866..ef513c3d 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -307,7 +307,7 @@ describe("initSentry", () => { }); it("is a no-op when VITE_SENTRY_DSN is undefined", () => { - vi.stubEnv("VITE_SENTRY_DSN", ""); + // Do not stub VITE_SENTRY_DSN — beforeEach calls vi.unstubAllEnvs() so it is truly undefined initSentry(); expect(mockInit).not.toHaveBeenCalled(); }); From da3bbc9bfdfc5a578901d8b7b6290a20436dbabe Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 13 Apr 2026 15:20:07 -0400 Subject: [PATCH 45/56] test(sentry): stub DEV=false in no-op DSN tests --- tests/lib/sentry.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index ef513c3d..3c6d388f 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -307,12 +307,14 @@ describe("initSentry", () => { }); it("is a no-op when VITE_SENTRY_DSN is undefined", () => { + vi.stubEnv("DEV", false); // Do not stub VITE_SENTRY_DSN — beforeEach calls vi.unstubAllEnvs() so it is truly undefined initSentry(); expect(mockInit).not.toHaveBeenCalled(); }); it("is a no-op when VITE_SENTRY_DSN is empty string", () => { + vi.stubEnv("DEV", false); vi.stubEnv("VITE_SENTRY_DSN", ""); initSentry(); expect(mockInit).not.toHaveBeenCalled(); From 712c647134536ec9693bb85a5af80352ce75d607 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 10:29:46 -0400 Subject: [PATCH 46/56] fix: address 14 PR review findings - fix SEAL_KEY fingerprint collision (full key comparison, not 8-char slice) - redesign _sessionKeyCache with fingerprint+purpose pattern - add Turnstile action binding validation (action:'seal') - fix Sentry allowUrls: anchored RegExp with boundary - fix DEPLOY.md: CSP origin check documented as strict - add TURNSTILE_SECRET_KEY to validate-deploy.sh - fix CSP rate-limit test to send valid requests with Origin - add tests: 30s timeout, script reuse, jira-refresh-token, SEAL_KEY rotation, Turnstile action mismatch integration - fix toBase64Url: spread instead of byte-by-byte concat - fix stale comment, remove redundant X-Requested-With --- DEPLOY.md | 6 +- scripts/validate-deploy.sh | 2 +- src/app/lib/proxy.ts | 3 +- src/app/lib/sentry.ts | 2 +- src/types/turnstile.d.ts | 1 + src/worker/crypto.ts | 5 +- src/worker/index.ts | 12 ++-- src/worker/session.ts | 29 +++++--- src/worker/turnstile.ts | 7 +- tests/app/lib/proxy.test.ts | 97 +++++++++++++++++++++++++- tests/lib/sentry.test.ts | 12 ++-- tests/worker/csp-report.test.ts | 8 +-- tests/worker/seal.test.ts | 118 +++++++++++++++++++++++++++----- tests/worker/turnstile.test.ts | 50 +++++++++++++- 14 files changed, 292 insertions(+), 60 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 8b6b2da3..eae601e5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -202,7 +202,7 @@ The Worker validates that the `PROXY_RATE_LIMITER` binding exists (via `typeof e **Origin check behavior:** - Sentry tunnel (`/api/error-reporting`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. The Sentry SDK always includes `Origin` in its `fetch()` calls from our SPA. -- CSP report tunnel (`/api/csp-report`): **soft** — allows absent Origin (browser CSP reports sent via the Reporting API may omit Origin), rejects only if Origin is present and does not match `ALLOWED_ORIGIN`. +- CSP report tunnel (`/api/csp-report`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. Same-origin CSP reports (via `report-uri`) always include Origin; the WAF exempts this endpoint so the Worker can enforce its own policy independently. Note: Origin and Sec-Fetch-Site headers can be spoofed by programmatic clients (curl, scripts). IP rate limiting is the primary defense; origin checks are defense-in-depth. @@ -275,8 +275,8 @@ not (http.request.uri.path eq "/api/error-reporting") **Action:** Block **Exemptions:** -- `/api/csp-report` is exempted because browser-generated CSP violation reports (via the Reporting API) may not include an `Origin` header. -- `/api/error-reporting` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. The exemption exists because the WAF expression cannot selectively allow absent-Origin for CSP while also blocking it for Sentry — the Worker handles both policies independently. +- `/api/csp-report` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. +- `/api/error-reporting` is exempted for the same reason — the Worker enforces its own strict origin check independently. Both endpoints are exempted so the Worker handles origin policies at the application layer, where per-endpoint logic is possible — the WAF expression is too coarse to distinguish per-endpoint behavior. **Notes:** - This uses **1 of the 5 free WAF custom rules** available on all plans. diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index 12288fc1..852184c6 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -24,7 +24,7 @@ else if ! SECRETS=$(wrangler secret list --json 2>&1); then fail "wrangler secret list failed — run: wrangler login" else - for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY; do + for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY; do echo "$SECRETS" | grep -q "\"name\":\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" done echo "$SECRETS" | grep -q '"name":"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index d5773dd8..b31df324 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -66,6 +66,7 @@ export async function acquireTurnstileToken(siteKey: string): Promise { try { const widgetId = window.turnstile.render(container, { sitekey: siteKey, + action: "seal", size: "invisible", execution: "execute", retry: "never", @@ -111,7 +112,6 @@ export async function proxyFetch( options?: RequestInit, ): Promise { const defaultHeaders: Record = { - "X-Requested-With": "fetch", "Content-Type": "application/json", }; @@ -123,6 +123,7 @@ export async function proxyFetch( const mergedHeaders = { ...defaultHeaders, ...callerHeaders, + // Always override — callers must not be able to spoof this header. "X-Requested-With": "fetch", }; diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index ad3ddd48..c88c12a0 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -106,7 +106,7 @@ export function initSentry(): void { profilesSampleRate: 0, // ── Only capture errors from our own code ─────────────────── - allowUrls: [window.location.origin], + allowUrls: [new RegExp(`^${window.location.origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[/\\?#])`)], // ── Scrub sensitive data before it leaves the browser ──────── beforeSend: beforeSendHandler, diff --git a/src/types/turnstile.d.ts b/src/types/turnstile.d.ts index c7eb1acb..e7415dff 100644 --- a/src/types/turnstile.d.ts +++ b/src/types/turnstile.d.ts @@ -3,6 +3,7 @@ interface TurnstileRenderOptions { sitekey: string; + action?: string; size?: "normal" | "compact" | "invisible" | "flexible"; execution?: "render" | "execute"; retry?: "auto" | "never"; diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts index 77619209..0d0a699c 100644 --- a/src/worker/crypto.ts +++ b/src/worker/crypto.ts @@ -6,10 +6,7 @@ export interface CryptoEnv { // ── Base64url utilities ──────────────────────────────────────────────────── export function toBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } + const binary = String.fromCharCode(...bytes); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } diff --git a/src/worker/index.ts b/src/worker/index.ts index ff5ee6fa..d3505ced 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -213,10 +213,8 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string // ── Sealed-token endpoint ──────────────────────────────────────────────────── const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token"]); -// Module-level cache for derived seal keys, keyed by ":". -// Seal key cache: keyed by purpose, invalidated when SEAL_KEY changes. -// Tracks a fingerprint of the SEAL_KEY (first 8 chars) to detect rotation -// without storing raw key material as a Map key. +// Module-level cache for derived seal keys, keyed by purpose. +// Invalidated on SEAL_KEY rotation via full-value fingerprint comparison. const _sealKeyCache = new Map(); let _sealKeyFingerprint = ""; @@ -238,7 +236,7 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P } const ip = request.headers.get("CF-Connecting-IP"); - const turnstileResult = await verifyTurnstile(turnstileToken, ip, env); + const turnstileResult = await verifyTurnstile(turnstileToken, ip, env, "seal"); if (!turnstileResult.success) { log("warn", "seal_turnstile_failed", { error_codes: turnstileResult.errorCodes }, request); return errorResponse("turnstile_failed", 403); @@ -275,9 +273,7 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P let sealed: string; try { // SC-8: derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) - // Keyed by purpose only — avoids storing raw key material as a Map key string. - // Fingerprint detects SEAL_KEY rotation without retaining the full secret. - const fingerprint = env.SEAL_KEY.slice(0, 8); + const fingerprint = env.SEAL_KEY; if (fingerprint !== _sealKeyFingerprint) { _sealKeyCache.clear(); _sealKeyFingerprint = fingerprint; diff --git a/src/worker/session.ts b/src/worker/session.ts index 7e6cb79a..7c0a8d47 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -32,16 +32,25 @@ const SESSION_HMAC_SALT = "github-tracker-session-v1"; const SESSION_HMAC_INFO = "session-hmac"; const SESSION_MAX_AGE = 28800; // 8 hours in seconds -// Module-level cache for derived session HMAC keys. -// SESSION_KEY is a deployment constant — safe to cache per-isolate (follows _dsnCache pattern). -// Keyed by raw secret string; supports both current and previous key without duplicate logic. +// Module-level cache for derived session HMAC keys, keyed by purpose ("current" | "prev"). +// Invalidated on SESSION_KEY rotation via compound fingerprint comparison. const _sessionKeyCache = new Map(); - -async function getSessionHmacKey(raw: string): Promise { - const cached = _sessionKeyCache.get(raw); +let _sessionKeyFingerprint = ""; + +async function getSessionHmacKey( + env: SessionEnv, + purpose: "current" | "prev" +): Promise { + const raw = purpose === "current" ? env.SESSION_KEY : env.SESSION_KEY_PREV!; + const fp = `${env.SESSION_KEY}:${env.SESSION_KEY_PREV ?? ""}`; + if (fp !== _sessionKeyFingerprint) { + _sessionKeyCache.clear(); + _sessionKeyFingerprint = fp; + } + const cached = _sessionKeyCache.get(purpose); if (cached !== undefined) return cached; const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); - _sessionKeyCache.set(raw, key); + _sessionKeyCache.set(purpose, key); return key; } @@ -60,7 +69,7 @@ export async function issueSession( }; const json = JSON.stringify(payload); - const hmacKey = await getSessionHmacKey(env.SESSION_KEY); + const hmacKey = await getSessionHmacKey(env, "current"); const signature = await signSession(json, hmacKey); // base64url(JSON(payload)).base64url(HMAC-SHA256(JSON(payload))) @@ -108,10 +117,10 @@ export async function parseSession( const payload = JSON.parse(json) as SessionPayload; // Verify HMAC signature (rotation-aware, using cached derived keys) - const currentKey = await getSessionHmacKey(env.SESSION_KEY); + const currentKey = await getSessionHmacKey(env, "current"); let valid = await verifySession(json, signature, currentKey); if (!valid && env.SESSION_KEY_PREV !== undefined) { - const prevKey = await getSessionHmacKey(env.SESSION_KEY_PREV); + const prevKey = await getSessionHmacKey(env, "prev"); valid = await verifySession(json, signature, prevKey); } if (!valid) return null; diff --git a/src/worker/turnstile.ts b/src/worker/turnstile.ts index a6f51d4f..298e7fe4 100644 --- a/src/worker/turnstile.ts +++ b/src/worker/turnstile.ts @@ -4,6 +4,7 @@ export interface TurnstileEnv { interface TurnstileResponse { success: boolean; + action?: string; "error-codes"?: string[]; } @@ -19,7 +20,8 @@ interface TurnstileResponse { export async function verifyTurnstile( token: string, ip: string | null, - env: TurnstileEnv + env: TurnstileEnv, + expectedAction?: string ): Promise<{ success: boolean; errorCodes?: string[] }> { const body = new FormData(); body.append("secret", env.TURNSTILE_SECRET_KEY); @@ -56,6 +58,9 @@ export async function verifyTurnstile( } if (data.success) { + if (expectedAction !== undefined && data.action !== expectedAction) { + return { success: false, errorCodes: ["action-mismatch"] }; + } return { success: true }; } diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 959836fe..0c4e0a1f 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -255,7 +255,7 @@ describe("acquireTurnstileToken", () => { expect(mockTurnstile.ready).toHaveBeenCalledOnce(); expect(mockTurnstile.render).toHaveBeenCalledWith( expect.any(HTMLDivElement), - expect.objectContaining({ retry: "never" }), + expect.objectContaining({ action: "seal", retry: "never" }), ); }); @@ -377,6 +377,101 @@ describe("acquireTurnstileToken", () => { }); }); +// ── acquireTurnstileToken — outer 30-second timeout ────────────────────────── + +describe("acquireTurnstileToken — 30-second outer timeout", () => { + let mod: typeof import("../../../src/app/lib/proxy"); + + beforeEach(async () => { + vi.useFakeTimers(); + mod = await loadModule(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("rejects with timeout message when Turnstile never calls ready callback", async () => { + const mockTurnstile = makeMockTurnstile(); + // ready() captures the callback but never calls it — simulates a hung Turnstile widget + mockTurnstile.ready = vi.fn(); + + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + const tokenPromise = mod.acquireTurnstileToken("test-site-key"); + // Prevent unhandled rejection during timer advancement + void tokenPromise.catch(() => {}); + + // Allow loadTurnstileScript (microtask) to resolve and setTimeout to register + await Promise.resolve(); + await Promise.resolve(); + + // Advance past the 30-second timeout + await vi.advanceTimersByTimeAsync(30_001); + + await expect(tokenPromise).rejects.toThrow( + "Turnstile challenge timed out after 30 seconds", + ); + }); +}); + +// ── acquireTurnstileToken — script reuse ───────────────────────────────────── + +describe("acquireTurnstileToken — script reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("appends the Turnstile script to only once across multiple calls", async () => { + const mod = await loadModule(); + + const mockTurnstile = makeMockTurnstile(); + mockTurnstile.execute.mockImplementation(() => { + mockTurnstile._resolveToken("reuse-token"); + }); + + vi.stubGlobal("window", { + ...window, + turnstile: mockTurnstile, + }); + + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + const el = node as HTMLScriptElement; + if (el.tagName === "SCRIPT") { + (el as unknown as { onload: (() => void) | null }).onload?.(); + return node; + } + return node; + }); + + const token1 = await mod.acquireTurnstileToken("test-site-key"); + expect(token1).toBe("reuse-token"); + + const token2 = await mod.acquireTurnstileToken("test-site-key"); + expect(token2).toBe("reuse-token"); + + const scriptAppends = appendSpy.mock.calls.filter( + ([node]) => (node as HTMLElement).tagName === "SCRIPT", + ); + expect(scriptAppends).toHaveLength(1); + }); +}); + // ── sealApiToken tests ──────────────────────────────────────────────────────── describe("sealApiToken", () => { diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 3c6d388f..6491274e 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -332,15 +332,15 @@ describe("initSentry", () => { ); }); - it("sets allowUrls to window.location.origin", () => { + it("sets allowUrls to a RegExp anchored to window.location.origin", () => { vi.stubEnv("DEV", false); vi.stubEnv("VITE_SENTRY_DSN", "https://test-key@o1.ingest.us.sentry.io/1"); vi.stubGlobal("location", { ...window.location, origin: "https://test.example.com" }); initSentry(); - expect(mockInit).toHaveBeenCalledWith( - expect.objectContaining({ - allowUrls: ["https://test.example.com"], - }), - ); + const [config] = mockInit.mock.calls[0] as [{ allowUrls: RegExp[] }]; + expect(config.allowUrls).toHaveLength(1); + expect(config.allowUrls[0]).toBeInstanceOf(RegExp); + expect(config.allowUrls[0].test("https://test.example.com/path")).toBe(true); + expect(config.allowUrls[0].test("https://test.example.com.evil.com/path")).toBe(false); }); }); diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 7998336d..541c03db 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -383,7 +383,7 @@ describe("Worker CSP report endpoint", () => { expect(report["violated-directive"].length).toBe(2048); }); - // ── Soft origin check ───────────────────────────────────────────────────── + // ── Strict origin check ─────────────────────────────────────────────────── it("rejects requests with wrong Origin with 403", async () => { const body = JSON.stringify({ "csp-report": { "document-uri": "https://gh.gordoncode.dev/", "violated-directive": "script-src" } }); @@ -422,15 +422,15 @@ describe("Worker CSP report endpoint", () => { for (let i = 0; i < 15; i++) { const req = new Request("https://gh.gordoncode.dev/api/csp-report", { method: "POST", - headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp, "Origin": ALLOWED_ORIGIN }, body, }); const resp = await worker.fetch(req, env); - expect(resp.status).not.toBe(429); + expect(resp.status).toBe(204); } const req = new Request("https://gh.gordoncode.dev/api/csp-report", { method: "POST", - headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp }, + headers: { "Content-Type": "application/csp-report", "CF-Connecting-IP": fixedIp, "Origin": ALLOWED_ORIGIN }, body, }); const resp = await worker.fetch(req, env); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 8872be10..ae314c4d 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -82,7 +82,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("valid request with all headers + mocked Turnstile returns sealed token", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest(); @@ -154,6 +154,32 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("turnstile_failed"); }); + it("request with Turnstile action mismatch returns 403 with turnstile_failed", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "wrong-action" }), { status: 200 }) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("turnstile_failed"); + }); + + it("request with Turnstile response missing action field returns 403 with turnstile_failed", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const req = makeSealRequest(); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("turnstile_failed"); + }); + it("request with missing Turnstile token returns 403 with turnstile_failed", async () => { const req = makeSealRequest({ turnstileToken: "" }); const res = await worker.fetch(req, makeEnv()); @@ -175,7 +201,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with Turnstile header exactly 2048 chars is not rejected by length guard", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const maxToken = "a".repeat(2048); @@ -191,7 +217,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request exceeding rate limit returns 429 with rate_limited and Retry-After header", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const rateLimiter = { limit: vi.fn().mockResolvedValue({ success: false }) }; @@ -206,7 +232,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request proceeds when rate limiter throws (fail-open)", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const rateLimiter = { limit: vi.fn().mockRejectedValue(new Error("binding unavailable")) }; @@ -223,7 +249,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with token exceeding 2048 chars returns 400 with invalid_request", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const longToken = "a".repeat(2049); @@ -237,7 +263,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with token exactly 2048 chars is accepted", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const maxToken = "a".repeat(2048); @@ -251,7 +277,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with missing purpose returns 400 with invalid_request", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { token: "ghp_test" } }); @@ -264,7 +290,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with empty purpose string returns 400 with invalid_request", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { token: "ghp_test", purpose: "" } }); @@ -277,7 +303,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with invalid purpose (not in VALID_PURPOSES) returns 400", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { token: "ghp_test", purpose: "github-pat" } }); @@ -290,7 +316,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("request with missing token returns 400 with invalid_request", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { purpose: "jira-api-token" } }); @@ -351,7 +377,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("successful POST to /api/proxy/seal does not set Access-Control-Allow-Origin", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest(); @@ -385,7 +411,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("first request issues a session cookie in Set-Cookie", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest(); @@ -403,7 +429,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("when sealToken fails due to invalid key, returns 500 with seal_failed", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); // Use an invalid (non-base64url) key to force a crypto failure in deriveKey @@ -422,7 +448,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("responses include security headers", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest(); @@ -437,7 +463,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("successful seal logs token_sealed event with purpose and token_length", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "jira-api-token" } }); @@ -453,6 +479,60 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(allLogText).not.toContain("ghp_abc123"); }); + // ── Second valid purpose value ──────────────────────────────────────────── + + it("valid request with purpose 'jira-refresh-token' returns 200 with sealed token", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "refresh_token_xyz", purpose: "jira-refresh-token" } }); + const res = await worker.fetch(req, makeEnv()); + + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + expect((json["sealed"] as string).length).toBeGreaterThan(0); + }); + + // ── SEAL_KEY rotation / cache invalidation ──────────────────────────────── + // These two tests run sequentially and share module-level _sealKeyCache state. + // The first primes the cache; the second rotates SEAL_KEY and verifies a distinct sealed output. + + it("SEAL_KEY rotation: request with original key produces sealed output (primes cache)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "rotation_test_token", purpose: "jira-api-token" } }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["sealed"]).toBe("string"); + // Store sealed output for comparison in the next test + (globalThis as Record)._rotationTestSealed1 = json["sealed"]; + }); + + it("SEAL_KEY rotation: request with rotated key succeeds and produces distinct sealed output", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + + const ROTATED_SEAL_KEY = "cm90YXRlZC1zZWFsLWtleQ=="; + const req = makeSealRequest({ body: { token: "rotation_test_token", purpose: "jira-api-token" } }); + const res = await worker.fetch(req, makeEnv({ SEAL_KEY: ROTATED_SEAL_KEY })); + expect(res.status).toBe(200); + const json = await res.json() as Record; + const sealed2 = json["sealed"] as string; + expect(typeof sealed2).toBe("string"); + + // Different key → different ciphertext (even with same plaintext + purpose) + const sealed1 = (globalThis as Record)._rotationTestSealed1 as string; + expect(sealed1).toBeDefined(); + expect(typeof sealed1).toBe("string"); + expect(sealed2).not.toBe(sealed1); + }); + // ── Missing CF-Connecting-IP and binding validation ──────────────────────── it("rejects proxy requests without CF-Connecting-IP with 400", async () => { @@ -471,7 +551,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("returns 503 when PROXY_RATE_LIMITER binding is missing", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const req = makeSealRequest({ body: { token: "ghp_test", purpose: "jira-api-token" } }); const env = makeEnv({ PROXY_RATE_LIMITER: undefined as unknown as Env["PROXY_RATE_LIMITER"] }); @@ -484,7 +564,7 @@ describe("Worker /api/proxy/seal endpoint", () => { describe("proxy IP pre-gate", () => { it("rejects proxy requests after IP threshold exceeded", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const env = makeEnv(); @@ -527,7 +607,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("does not issue session cookie when IP pre-gate rejects", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); const env = makeEnv(); @@ -565,7 +645,7 @@ describe("Worker /api/proxy/seal endpoint", () => { it("IP pre-gate is independent of session-based rate limiter", async () => { globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }) + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) ); // A request that passes the IP pre-gate should still go through session + CF rate limiter as normal diff --git a/tests/worker/turnstile.test.ts b/tests/worker/turnstile.test.ts index 48388e81..bda3d806 100644 --- a/tests/worker/turnstile.test.ts +++ b/tests/worker/turnstile.test.ts @@ -26,7 +26,7 @@ afterEach(() => { // ── verifyTurnstile ───────────────────────────────────────────────────────── describe("verifyTurnstile", () => { - it("returns success: true on successful verification", async () => { + it("returns success: true on successful verification (no action binding)", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200, @@ -38,6 +38,54 @@ describe("verifyTurnstile", () => { expect(result).toEqual({ success: true }); }); + it("returns success: true when expectedAction matches response action", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "seal" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV, "seal"); + expect(result).toEqual({ success: true }); + }); + + it("returns action-mismatch when expectedAction does not match response action", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "other-action" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV, "seal"); + expect(result).toEqual({ success: false, errorCodes: ["action-mismatch"] }); + }); + + it("returns action-mismatch when expectedAction is provided but response action is missing", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV, "seal"); + expect(result).toEqual({ success: false, errorCodes: ["action-mismatch"] }); + }); + + it("does not validate action when expectedAction is omitted", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "anything" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV); + expect(result).toEqual({ success: true }); + }); + it("returns success: false with errorCodes on failed verification", async () => { mockFetch.mockResolvedValueOnce( new Response( From dbacd73f4d567f27a4a24a29dd4e5f0c7aad5745 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 10:39:25 -0400 Subject: [PATCH 47/56] refactor(worker): reuse crypto.ts base64url helpers in session.ts - replace inline btoa/atob+replace chains with toBase64Url/fromBase64Url - remove spurious blank line in sentry-tunnel.test.ts --- src/worker/session.ts | 12 ++++-------- tests/worker/sentry-tunnel.test.ts | 1 - 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/worker/session.ts b/src/worker/session.ts index 7c0a8d47..cc8e0481 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -14,6 +14,8 @@ import { deriveKey, signSession, verifySession, + toBase64Url, + fromBase64Url, } from "./crypto"; export interface SessionEnv { @@ -73,10 +75,7 @@ export async function issueSession( const signature = await signSession(json, hmacKey); // base64url(JSON(payload)).base64url(HMAC-SHA256(JSON(payload))) - const encodedPayload = btoa(json) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); + const encodedPayload = toBase64Url(new TextEncoder().encode(json)); const cookieValue = `${encodedPayload}.${signature}`; const cookie = `${SESSION_COOKIE_NAME}=${cookieValue}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}`; @@ -110,10 +109,7 @@ export async function parseSession( const signature = cookieValue.slice(dotIndex + 1); // Decode and parse the payload - const paddedPayload = - encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); - const padding = (4 - (paddedPayload.length % 4)) % 4; - const json = atob(paddedPayload + "=".repeat(padding)); + const json = new TextDecoder().decode(fromBase64Url(encodedPayload)); const payload = JSON.parse(json) as SessionPayload; // Verify HMAC signature (rotation-aware, using cached derived keys) diff --git a/tests/worker/sentry-tunnel.test.ts b/tests/worker/sentry-tunnel.test.ts index 75eb541f..42a653ab 100644 --- a/tests/worker/sentry-tunnel.test.ts +++ b/tests/worker/sentry-tunnel.test.ts @@ -44,7 +44,6 @@ function makeTunnelRequest(body: string, options: { origin?: string | null; ip?: }); } - describe("Sentry tunnel (/api/error-reporting)", () => { let originalFetch: typeof globalThis.fetch; let consoleSpy: { From 7d788f862295a980f97b726416bc83b349463ce1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 10:55:18 -0400 Subject: [PATCH 48/56] docs(deploy): note WAF exemptions are simplifiable post-deploy --- DEPLOY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DEPLOY.md b/DEPLOY.md index eae601e5..550a524f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -278,6 +278,8 @@ not (http.request.uri.path eq "/api/error-reporting") - `/api/csp-report` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. - `/api/error-reporting` is exempted for the same reason — the Worker enforces its own strict origin check independently. Both endpoints are exempted so the Worker handles origin policies at the application layer, where per-endpoint logic is possible — the WAF expression is too coarse to distinguish per-endpoint behavior. +> **Simplification opportunity:** Both tunnel endpoints now enforce strict Origin checks at the Worker level (reject absent or mismatched Origin). The WAF exemptions above are therefore redundant — removing them would let the WAF block originless requests to these endpoints before they reach the Worker, saving a Worker invocation per blocked request. To simplify: delete the two `not (http.request.uri.path eq ...)` lines from the WAF expression. The Worker's strict checks remain as defense-in-depth. Safe to apply any time after this PR is deployed. + **Notes:** - This uses **1 of the 5 free WAF custom rules** available on all plans. - Blocks scanners, `curl` without `Origin`, and cross-site browser attacks before the Worker runs (never billed as a Worker request). From 4953c38e9b921cc93b73f84d544ba2d3e5a0cc8c Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 11:19:22 -0400 Subject: [PATCH 49/56] docs(deploy): removes one-time WAF note --- DEPLOY.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 550a524f..eae601e5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -278,8 +278,6 @@ not (http.request.uri.path eq "/api/error-reporting") - `/api/csp-report` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. - `/api/error-reporting` is exempted for the same reason — the Worker enforces its own strict origin check independently. Both endpoints are exempted so the Worker handles origin policies at the application layer, where per-endpoint logic is possible — the WAF expression is too coarse to distinguish per-endpoint behavior. -> **Simplification opportunity:** Both tunnel endpoints now enforce strict Origin checks at the Worker level (reject absent or mismatched Origin). The WAF exemptions above are therefore redundant — removing them would let the WAF block originless requests to these endpoints before they reach the Worker, saving a Worker invocation per blocked request. To simplify: delete the two `not (http.request.uri.path eq ...)` lines from the WAF expression. The Worker's strict checks remain as defense-in-depth. Safe to apply any time after this PR is deployed. - **Notes:** - This uses **1 of the 5 free WAF custom rules** available on all plans. - Blocks scanners, `curl` without `Origin`, and cross-site browser attacks before the Worker runs (never billed as a Worker request). From 388f442ad217116e1dd0253fab9e4082b7f373f8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 14:13:50 -0400 Subject: [PATCH 50/56] =?UTF-8?q?fix:=20address=20PR=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20security=20docs,=20tests,=20code=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move security analysis (rate limits, WAF exemptions, threat model) from DEPLOY.md to hack/docs/security-runbook.md - Add SENTRY_SECURITY_TOKEN to .dev.vars.example, DEPLOY.md, validate-deploy.sh - Simplify verifySession: 3 crypto ops to 1 (crypto.subtle.verify) - Consolidate getCorsHeaders/getProxyCorsHeaders into buildCorsHeaders - Fix CSP scrub ordering: scrub URLs before 2048-char truncation - Remove design-doc codes (SDR-/SC-/ADV-) from all source and test files - Remove purpose.length > 64 dead code from seal endpoint - Fix wrangler.toml rate limit: period=10 limit=10 (CF free-tier) - Add tests: CSP token prefix scrubbing, SENTRY_SECURITY_TOKEN forwarding, non-string token validation, Set-Cookie on 405, crypto.subtle.verify spy - Export shared ALLOWED_ORIGIN from test helpers --- .dev.vars.example | 2 + DEPLOY.md | 49 ++------------------- e2e/settings.spec.ts | 2 +- scripts/validate-deploy.sh | 1 + src/app/lib/url.ts | 4 +- src/app/pages/OAuthCallback.tsx | 2 +- src/app/services/api-usage.ts | 2 +- src/app/services/poll.ts | 2 +- src/app/stores/auth.ts | 2 +- src/worker/crypto.ts | 27 ++++-------- src/worker/index.ts | 71 ++++++++++++------------------ src/worker/session.ts | 2 +- tests/services/api-usage.test.ts | 2 +- tests/stores/auth.test.ts | 2 +- tests/worker/crypto.test.ts | 11 ++++- tests/worker/csp-report.test.ts | 56 +++++++++++++++++++++-- tests/worker/helpers.ts | 2 + tests/worker/oauth.test.ts | 12 +++-- tests/worker/seal.test.ts | 34 +++++++++++--- tests/worker/sentry-tunnel.test.ts | 32 ++++++++++++-- tests/worker/validation.test.ts | 3 +- wrangler.toml | 4 +- 22 files changed, 183 insertions(+), 141 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index 0780bbff..b2ba5081 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -4,3 +4,5 @@ ALLOWED_ORIGIN=http://localhost:5173 SESSION_KEY=your-base64-encoded-32-byte-key SEAL_KEY=your-base64-encoded-32-byte-key TURNSTILE_SECRET_KEY=your-turnstile-secret-from-cf-dashboard +# Optional: only needed if Sentry "Allowed Domains" is configured in your Sentry project settings +# SENTRY_SECURITY_TOKEN=your-sentry-security-token diff --git a/DEPLOY.md b/DEPLOY.md index eae601e5..8517a398 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -175,45 +175,7 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i ### Tunnel Endpoint Security -The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive untrusted browser data and forward it to Sentry. They are hardened with layered fail-fast guards: - -**Layered defense model (applied in order):** -1. **IP rate limit** (catches all abuse including curl — headers are irrelevant) -2. **Origin check** (catches cross-origin browser abuse and lazy curl scripts) -3. **DSN/project validation** (catches misdirected requests) - -Content-Length pre-check fires between origin check and DSN validation but is an optimization gate, not a security layer (see Content-Length section below). - -**Per-endpoint IP rate limits (in-memory, per-isolate):** -- Token exchange (`/api/oauth/token`): 10 requests/min per IP -- Sentry tunnel (`/api/error-reporting`): 15 requests/min per IP -- CSP report tunnel (`/api/csp-report`): 15 requests/min per IP -- Proxy pre-gate (`/api/proxy/*`, `/api/jira/*`): 60 requests/min per IP (complements CF rate limiter binding) - -Note: these counters are in-memory and do not survive isolate restarts. A fresh isolate gets a clean slate. CF isolates are short-lived, so this gates burst abuse within a single isolate lifetime — the intended use case. - -Token exchange also uses the durable CF rate limiter binding (`PROXY_RATE_LIMITER`) with key `token:{ip}`, enforcing the limit globally across isolates. - -**`CF-Connecting-IP` requirement:** -All rate-limited endpoints require the `CF-Connecting-IP` header and return 400 if absent. Cloudflare's proxy layer sets this header on all production requests. Miniflare sets it to the connecting client's address in `wrangler dev`. There is no fallback — requests without this header are rejected outright. - -**`PROXY_RATE_LIMITER` binding validation:** -The Worker validates that the `PROXY_RATE_LIMITER` binding exists (via `typeof env.PROXY_RATE_LIMITER?.limit === "function"`) before calling it. A missing binding indicates a deployment misconfiguration (missing `[[ratelimits]]` in `wrangler.toml`) and returns 503. Transient `.limit()` errors on an existing binding fail open — the in-memory IP pre-gate still protects. - -**Origin check behavior:** -- Sentry tunnel (`/api/error-reporting`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. The Sentry SDK always includes `Origin` in its `fetch()` calls from our SPA. -- CSP report tunnel (`/api/csp-report`): **strict** — rejects if Origin is absent or does not match `ALLOWED_ORIGIN`. Same-origin CSP reports (via `report-uri`) always include Origin; the WAF exempts this endpoint so the Worker can enforce its own policy independently. - -Note: Origin and Sec-Fetch-Site headers can be spoofed by programmatic clients (curl, scripts). IP rate limiting is the primary defense; origin checks are defense-in-depth. - -**CSP field sanitization:** -All string fields in CSP report bodies are sanitized before forwarding to Sentry: control characters (`\x00`–`\x08`, `\x0B`, `\x0C`, `\x0E`–`\x1F`, `\x7F`) are stripped and fields are capped at 2048 characters. This prevents log/SIEM injection via attacker-crafted CSP report values. URL fields are additionally scrubbed of OAuth parameters (`code`, `state`, `access_token`). - -**Content-Length pre-check (Sentry: 256 KB, CSP: 64 KB):** -Both tunnel endpoints check the `Content-Length` header before reading the request body as an optimization. If the declared size exceeds the per-endpoint maximum, the request is rejected with 413 without buffering the body. The post-read size check remains the authoritative guard — Content-Length can be absent (chunked transfer, passes through) or spoofed (attacker declares small size but sends large body, caught by the post-read check). - -**CSP report fan-out amplification:** -A single POST to `/api/csp-report` can contain up to 20 individual violation reports (via the Reporting API batch format), each triggering a separate outbound subrequest to Sentry's security endpoint. With the 15/min rate limit, worst case is 15 × 20 = **300 outbound subrequests per minute per IP**. This is bounded by the rate limiter on inbound requests and the 20-report cap enforced in code (`scrubbedPayloads.slice(0, 20)`). +The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive untrusted browser data and forward it to Sentry. They are hardened with layered fail-fast guards (IP rate limit → Origin check → DSN validation). See `hack/docs/security-runbook.md` for the full threat model, per-endpoint rate limit values, Origin check behavior, CSP field sanitization details, Content-Length pre-check semantics, and fan-out amplification analysis. ## Local Development @@ -274,9 +236,7 @@ not (http.request.uri.path eq "/api/error-reporting") ``` **Action:** Block -**Exemptions:** -- `/api/csp-report` is exempted because the Worker enforces its own strict origin check (rejects missing or mismatched Origin), making the WAF exemption safe. -- `/api/error-reporting` is exempted for the same reason — the Worker enforces its own strict origin check independently. Both endpoints are exempted so the Worker handles origin policies at the application layer, where per-endpoint logic is possible — the WAF expression is too coarse to distinguish per-endpoint behavior. +**Exemptions:** `/api/csp-report` and `/api/error-reporting` are excluded because the Worker enforces its own strict origin check on both endpoints. See `hack/docs/security-runbook.md` for exemption rationale. **Notes:** - This uses **1 of the 5 free WAF custom rules** available on all plans. @@ -296,9 +256,7 @@ not (http.request.uri.path eq "/api/error-reporting") **Rate:** 60 requests per 10 seconds per IP **Action:** Block for 60 seconds -**Notes:** -- `OPTIONS` (CORS preflight) is excluded from counting to avoid blocking legitimate preflight requests. -- Provides globally-consistent rate limiting that runs before the Worker (not per-location like Workers Rate Limiting Binding). +See `hack/docs/security-runbook.md` for implementation details. --- @@ -323,6 +281,7 @@ wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard - `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`. - `SENTRY_DSN` (Worker secret, set via `wrangler secret put SENTRY_DSN`): used by both the Sentry tunnel endpoint for DSN validation and the `@sentry/cloudflare` SDK for direct worker-side error capture. Sentry DSNs are public keys — they authorize sending events, not reading them. Must match the `VITE_SENTRY_DSN` build-time env var; a mismatch causes tunnel requests to return 403. +- `SENTRY_SECURITY_TOKEN` (**optional**, set via `wrangler secret put SENTRY_SECURITY_TOKEN`): only needed if you have configured "Allowed Domains" in your Sentry project's security settings. The Worker sends this token as the `X-Sentry-Token` HTTP header on outbound requests to Sentry's envelope and CSP report endpoints. Leave unset if Allowed Domains is not configured. - `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`. - `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key). - `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key). diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 9c9e4e98..cdc85c65 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -80,7 +80,7 @@ test("sign out clears auth and redirects to login", async ({ page }) => { ); expect(authToken).toBeNull(); - // Verify config was reset (SDR-016 data leakage prevention). + // Verify config was reset (data leakage prevention). // The persistence effect may re-write defaults, so check that user-specific // data (selectedOrgs, onboardingComplete) was cleared rather than checking null. const configEntry = await page.evaluate(() => diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index 852184c6..a758dc50 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -28,6 +28,7 @@ else echo "$SECRETS" | grep -q "\"name\":\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" done echo "$SECRETS" | grep -q '"name":"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" + echo "$SECRETS" | grep -q '"name":"SENTRY_SECURITY_TOKEN"' || warn "CF Worker secret 'SENTRY_SECURITY_TOKEN' not set — only needed if Sentry Allowed Domains is configured" fi fi fi diff --git a/src/app/lib/url.ts b/src/app/lib/url.ts index 6f040006..e49342de 100644 --- a/src/app/lib/url.ts +++ b/src/app/lib/url.ts @@ -1,7 +1,7 @@ /** * Validates that a URL points to GitHub before opening it. - * Uses URL constructor for proper hostname parsing (ADV-013). - * Defense-in-depth against tampered cache data (SDR-012). + * Uses URL constructor for proper hostname parsing. + * Defense-in-depth against tampered cache data. */ export function isSafeGitHubUrl(url: string): boolean { try { diff --git a/src/app/pages/OAuthCallback.tsx b/src/app/pages/OAuthCallback.tsx index 335207ac..ed605164 100644 --- a/src/app/pages/OAuthCallback.tsx +++ b/src/app/pages/OAuthCallback.tsx @@ -20,7 +20,7 @@ export default function OAuthCallback() { const code = params.get("code"); const stateFromUrl = params.get("state"); - // Retrieve and immediately clear stored state (single-use, SDR-002) + // Retrieve and immediately clear stored state (single-use) const storedState = sessionStorage.getItem(OAUTH_STATE_KEY); sessionStorage.removeItem(OAUTH_STATE_KEY); diff --git a/src/app/services/api-usage.ts b/src/app/services/api-usage.ts index f25c5f1a..ab300fef 100644 --- a/src/app/services/api-usage.ts +++ b/src/app/services/api-usage.ts @@ -103,7 +103,7 @@ export function resetUsageData(): void { } export function clearUsageData(): void { - // Cancel any pending flush debounce timer before removing localStorage (SDR-012) + // Cancel any pending flush debounce timer before removing localStorage if (_flushTimer !== null) { clearTimeout(_flushTimer); _flushTimer = null; diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 3aca7658..5d44f32d 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -331,7 +331,7 @@ function withJitter(intervalMs: number): number { * Creates a poll coordinator that: * - Triggers an immediate fetch on init * - Polls at getInterval() seconds (reactive — restarts when interval changes) - * - If getInterval() === 0, disables auto-polling (SDR-017) + * - If getInterval() === 0, disables auto-polling * - Continues polling in background tabs when notifications gate is available * (304 responses make background polls near-zero cost). When the gate is * disabled (fine-grained PAT or missing notifications scope), background diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 4c14fe19..174b1090 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -81,7 +81,7 @@ export function clearAuth(): void { localStorage.removeItem(DASHBOARD_STORAGE_KEY); _setToken(null); setUser(null); - // Clear IndexedDB cache to prevent data leakage between users (SDR-016) + // Clear IndexedDB cache to prevent data leakage between users clearCache().catch((err) => { console.warn("[auth] Cache clear failed during logout:", err); Sentry.captureException(err, { tags: { source: "auth-logout-cache-clear" } }); diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts index 0d0a699c..a980c299 100644 --- a/src/worker/crypto.ts +++ b/src/worker/crypto.ts @@ -29,8 +29,8 @@ export function fromBase64Url(str: string): Uint8Array { * - usage "encrypt" → AES-256-GCM key * - usage "sign" → HMAC-SHA256 key * - * The info parameter MUST include a purpose string for token audience binding - * (SC-8). Pass e.g. "aes-gcm-key:" or "session-hmac" so keys derived + * The info parameter MUST include a purpose string for token audience binding. + * Pass e.g. "aes-gcm-key:" or "session-hmac" so keys derived * for different purposes are cryptographically isolated. */ export async function deriveKey( @@ -138,7 +138,7 @@ export async function unsealToken( /** * Unseals a token, falling back to prevKey if currentKey fails. * Both salt and info must match the values used during sealing. - * SC-8: info MUST include a purpose string for token audience binding. + * The info parameter MUST include a purpose string for token audience binding. */ export async function unsealTokenWithRotation( sealed: string, @@ -175,12 +175,10 @@ export async function signSession( } /** - * Verifies an HMAC-SHA256 signature using crypto.subtle.timingSafeEqual - * (Cloudflare Workers extension) for an explicit constant-time guarantee. - * - * Both inputs are hashed to SHA-256 before comparison so timingSafeEqual - * always receives equal-length buffers — no early-return length guard needed. - * This follows Cloudflare's recommended pattern for timing-attack protection. + * Verifies an HMAC-SHA256 signature using crypto.subtle.verify. + * Cloudflare Workers implements this with constant-time comparison; + * the Web Crypto spec does not mandate it, but this is the + * platform-recommended pattern over manual sign() + comparison. */ export async function verifySession( payload: string, @@ -196,16 +194,7 @@ export async function verifySession( const payloadBytes = new TextEncoder().encode(payload); try { - const expected = new Uint8Array( - await crypto.subtle.sign("HMAC", key, payloadBytes) - ); - // Hash both to fixed 32 bytes so timingSafeEqual never sees mismatched - // lengths and the comparison is unconditionally constant-time. - const [hashA, hashB] = await Promise.all([ - crypto.subtle.digest("SHA-256", sigBytes.buffer as ArrayBuffer), - crypto.subtle.digest("SHA-256", expected.buffer as ArrayBuffer), - ]); - return crypto.subtle.timingSafeEqual(hashA, hashB); + return await crypto.subtle.verify("HMAC", key, sigBytes.buffer as ArrayBuffer, payloadBytes); } catch { return false; } diff --git a/src/worker/index.ts b/src/worker/index.ts index d3505ced..639eb43a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -26,7 +26,6 @@ export interface Env extends CryptoEnv, SessionEnv, TurnstileEnv { PROXY_RATE_LIMITER: RateLimiter; // Workers Rate Limiting Binding } -// Predefined error strings only (SDR-006) type ErrorCode = | "token_exchange_failed" | "invalid_request" @@ -140,34 +139,19 @@ function checkContentLength(request: Request, maxBytes: number): boolean { return parsed <= maxBytes; } -// CORS: strict equality only (SDR-004) -function getCorsHeaders( - requestOrigin: string | null, - allowedOrigin: string -): Record { - if (requestOrigin === allowedOrigin) { - return { - "Access-Control-Allow-Origin": allowedOrigin, - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Content-Type", - "Vary": "Origin", - }; - } - return {}; -} - -// ── Proxy CORS headers ───────────────────────────────────────────────────── -// SC-7: Must check requestOrigin === allowedOrigin before reflecting. +// Must check requestOrigin === allowedOrigin before reflecting. // Returns empty object if no match — never reflects untrusted origins. -function getProxyCorsHeaders( +function buildCorsHeaders( requestOrigin: string | null, - allowedOrigin: string + allowedOrigin: string, + methods: string, + allowHeaders: string ): Record { if (requestOrigin !== allowedOrigin) return {}; return { "Access-Control-Allow-Origin": allowedOrigin, - "Access-Control-Allow-Methods": "POST, GET", - "Access-Control-Allow-Headers": "Content-Type, X-Requested-With, cf-turnstile-response", + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": allowHeaders, "Vary": "Origin", }; } @@ -190,7 +174,7 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string // Legitimate SPA requests are same-origin and don't trigger preflight, // so this handler exists only to explicitly reject cross-origin preflights. if (request.method === "OPTIONS") { - const corsHeaders = getProxyCorsHeaders(origin, env.ALLOWED_ORIGIN); + const corsHeaders = buildCorsHeaders(origin, env.ALLOWED_ORIGIN, "POST, GET", "Content-Type, X-Requested-With, cf-turnstile-response"); if (Object.keys(corsHeaders).length === 0) { return new Response(null, { status: 403, headers: SECURITY_HEADERS }); } @@ -203,7 +187,7 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string const result = validateProxyRequest(request, env.ALLOWED_ORIGIN); if (!result.ok) { log("warn", "proxy_validation_failed", { code: result.code, pathname }, request); - const corsHeaders = getProxyCorsHeaders(origin, env.ALLOWED_ORIGIN); + const corsHeaders = buildCorsHeaders(origin, env.ALLOWED_ORIGIN, "POST, GET", "Content-Type, X-Requested-With, cf-turnstile-response"); return errorResponse(result.code as ErrorCode, result.status, corsHeaders); } @@ -262,17 +246,17 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P if (token.length > 2048) { return errorResponse("invalid_request", 400); } - // SC-8: purpose field required for token audience binding + // Purpose field required for token audience binding if (typeof purpose !== "string" || purpose.length === 0) { return errorResponse("invalid_request", 400); } - if (purpose.length > 64 || !VALID_PURPOSES.has(purpose)) { + if (!VALID_PURPOSES.has(purpose)) { return errorResponse("invalid_request", 400); } let sealed: string; try { - // SC-8: derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) + // Derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) const fingerprint = env.SEAL_KEY; if (fingerprint !== _sealKeyFingerprint) { _sealKeyCache.clear(); @@ -285,7 +269,7 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P } sealed = await sealToken(token, key); } catch (err) { - // SC-9: log error server-side but DO NOT include crypto error in response + // Log error server-side — do not expose crypto error details in response log("error", "seal_failed", { error: err instanceof Error ? err.message : "unknown", }, request); @@ -293,7 +277,6 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P return errorResponse("seal_failed", 500); } - // SC-11: log seal operations (sessionId for correlation) log("info", "token_sealed", { sessionId, purpose, @@ -489,17 +472,17 @@ function sanitizeCspField(value: unknown): unknown { function scrubCspReportBody(body: Record): Record { const scrubbed = { ...body }; - // Sanitize all string fields — attacker-controlled CSP report bodies are forwarded to Sentry - for (const key of Object.keys(scrubbed)) { - scrubbed[key] = sanitizeCspField(scrubbed[key]); - } - // Legacy report-uri format uses kebab-case keys — scrub OAuth params from URLs - for (const key of ["document-uri", "blocked-uri", "source-file", "referrer"]) { + // Scrub OAuth params and token prefixes from URL fields FIRST (before truncation) + const urlKeys = [ + "document-uri", "blocked-uri", "source-file", "referrer", + "documentURL", "blockedURL", "sourceFile", + ]; + for (const key of urlKeys) { 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]); + // Then sanitize all string fields (control-char strip + length cap) + for (const key of Object.keys(scrubbed)) { + scrubbed[key] = sanitizeCspField(scrubbed[key]); } return scrubbed; } @@ -608,7 +591,7 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 204, headers: SECURITY_HEADERS }); } -// GitHub OAuth code format validation (SDR-005): alphanumeric, hyphens, underscores, 1-40 chars. +// GitHub OAuth code format validation: alphanumeric, hyphens, underscores, 1-40 chars. // GitHub's code format is undocumented and has changed historically — validate // loosely here; GitHub's server validates the actual code. const VALID_CODE_RE = /^[a-zA-Z0-9_-]{1,40}$/; @@ -702,7 +685,7 @@ async function handleTokenExchange( const code = (body as Record)["code"] as string; - // Strict code format validation before touching GitHub (SDR-005) + // Strict code format validation before touching GitHub if (!VALID_CODE_RE.test(code)) { log("warn", "token_exchange_invalid_code_format", { code_length: code.length, @@ -744,7 +727,7 @@ async function handleTokenExchange( return errorResponse("token_exchange_failed", 400, cors); } - // GitHub returns 200 even on error — check for error field (SDR-006) + // GitHub returns 200 even on error — check for error field if ( typeof githubData["error"] === "string" || typeof githubData["access_token"] !== "string" @@ -788,7 +771,7 @@ export default Sentry.withSentry( async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { const url = new URL(request.url); const origin = request.headers.get("Origin"); - const cors = getCorsHeaders(origin, env.ALLOWED_ORIGIN); + const cors = buildCorsHeaders(origin, env.ALLOWED_ORIGIN, "POST", "Content-Type"); const corsMatched = Object.keys(cors).length > 0; // Log all API requests (skip static asset requests to reduce noise) @@ -860,7 +843,7 @@ export default Sentry.withSentry( }); } - // Step 3: Session middleware — ensureSession never throws (SDR-003) + // Step 3: Session middleware — ensureSession never throws const { sessionId, setCookie } = await ensureSession(request, env); // Step 4: Durable rate limiting using session ID as key. diff --git a/src/worker/session.ts b/src/worker/session.ts index cc8e0481..82971ccf 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -1,6 +1,6 @@ // Session cookie infrastructure for proxy request binding. // -// SDR-001: The __Host-session cookie is for rate-limiting binding ONLY, +// The __Host-session cookie is for rate-limiting binding ONLY, // NOT authentication. It proves a browser initiated the request; it does // not prove who the user is. API tokens are managed separately via sealed // blobs in localStorage. diff --git a/tests/services/api-usage.test.ts b/tests/services/api-usage.test.ts index 325b4cfa..08d4a33f 100644 --- a/tests/services/api-usage.test.ts +++ b/tests/services/api-usage.test.ts @@ -277,7 +277,7 @@ describe("clearUsageData", () => { expect(mod.getUsageResetAt()).toBeNull(); }); - it("cancels a pending flush timer before removing (SDR-012)", () => { + it("cancels a pending flush timer before removing", () => { mod.trackApiCall("lightSearch", "graphql"); // Timer is set but not yet fired (< 500ms) mod.clearUsageData(); diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index e40edcf1..ca6974df 100644 --- a/tests/stores/auth.test.ts +++ b/tests/stores/auth.test.ts @@ -138,7 +138,7 @@ describe("clearAuth", () => { expect(localStorageMock.getItem("github-tracker:auth-token")).toBeNull(); }); - it("removes config and view keys from localStorage (SDR-016)", () => { + it("removes config and view keys from localStorage", () => { localStorageMock.setItem("github-tracker:config", "{}"); localStorageMock.setItem("github-tracker:view", "{}"); mod.clearAuth(); diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts index 9817f6d9..6d6e1115 100644 --- a/tests/worker/crypto.test.ts +++ b/tests/worker/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { test, fc } from "@fast-check/vitest"; import { toBase64Url, @@ -260,6 +260,15 @@ describe("signSession / verifySession", () => { const longSig = toBase64Url(new Uint8Array(33)); expect(await verifySession("payload-data", longSig, key)).toBe(false); }); + + it("uses crypto.subtle.verify for constant-time comparison", async () => { + const verifySpy = vi.spyOn(crypto.subtle, "verify"); + const key = await deriveKey(KEY_A, "github-tracker-session-v1", "session-hmac", "sign"); + const sig = await signSession("test-payload", key); + await verifySession("test-payload", sig, key); + expect(verifySpy).toHaveBeenCalledWith("HMAC", key, expect.any(ArrayBuffer), expect.any(Uint8Array)); + verifySpy.mockRestore(); + }); }); // ── Known-Answer Tests (KAT) ───────────────────────────────────────────── diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index 541c03db..4f4af9c7 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; -import { collectLogs, findLog } from "./helpers"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; function makeEnv(overrides: Partial = {}): Env { return { @@ -128,6 +126,26 @@ describe("Worker CSP report endpoint", () => { ); }); + it("scrubs bare GitHub token prefixes from URL fields", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/app", + "blocked-uri": "https://example.com/callback#ghu_abc123def456", + "source-file": "https://gh.gordoncode.dev/app.js?ref=ghp_secrettoken123", + "violated-directive": "script-src 'self'", + }, + }); + const req = makeCspRequest(body); + const env = makeEnv(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await worker.fetch(req, env); + const sentPayload = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentPayload["csp-report"]["blocked-uri"]).not.toContain("ghu_abc123"); + expect(sentPayload["csp-report"]["blocked-uri"]).toContain("ghu_[REDACTED]"); + expect(sentPayload["csp-report"]["source-file"]).not.toContain("ghp_secrettoken"); + expect(sentPayload["csp-report"]["source-file"]).toContain("ghp_[REDACTED]"); + }); + it("handles report-to format (application/reports+json)", async () => { const body = JSON.stringify([ { @@ -495,4 +513,36 @@ describe("Worker CSP report endpoint", () => { const resp = await worker.fetch(req, makeEnv()); expect(resp.status).toBe(204); }); + + // ── SENTRY_SECURITY_TOKEN forwarding ────────────────────────────────────── + + it("forwards X-Sentry-Token header when SENTRY_SECURITY_TOKEN is set", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv({ SENTRY_SECURITY_TOKEN: "my-csp-secret" })); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Sentry-Token"]).toBe("my-csp-secret"); + }); + + it("does not send X-Sentry-Token header when SENTRY_SECURITY_TOKEN is not set", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv({ SENTRY_SECURITY_TOKEN: undefined })); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Sentry-Token"]).toBeUndefined(); + }); }); diff --git a/tests/worker/helpers.ts b/tests/worker/helpers.ts index e644efb5..4c703522 100644 --- a/tests/worker/helpers.ts +++ b/tests/worker/helpers.ts @@ -1,5 +1,7 @@ import { vi } from "vitest"; +export const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; + /** Parse all structured log calls from a console spy, returning {level, entry} tuples. */ export function collectLogs(spies: { info: ReturnType; diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index f140a003..bc05a14c 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; -import { collectLogs, findLog } from "./helpers"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; function makeEnv(overrides: Partial = {}): Env { return { @@ -270,7 +268,7 @@ describe("Worker OAuth endpoint", () => { const json = await res.json() as Record; expect(json["error"]).toBe("token_exchange_failed"); - // GitHub error description must NOT be forwarded (SDR-006) + // GitHub error description must NOT be forwarded expect(JSON.stringify(json)).not.toContain("bad_verification_code"); expect(JSON.stringify(json)).not.toContain("incorrect"); }); @@ -384,7 +382,7 @@ describe("Worker OAuth endpoint", () => { expect(res.status).toBe(400); const json = await res.json() as Record; expect(json["error"]).toBe("token_exchange_failed"); - // Stack trace must not be in response (SDR-006) + // Stack trace must not be in response expect(JSON.stringify(json)).not.toContain("Error"); }); @@ -435,7 +433,7 @@ describe("Worker OAuth endpoint", () => { expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); }); - it("CORS headers are absent for non-matching origin (SDR-004)", async () => { + it("CORS headers are absent for non-matching origin", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200, @@ -450,7 +448,7 @@ describe("Worker OAuth endpoint", () => { expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); }); - it("CORS headers are absent for substring-matching origin (SDR-004 strict equality)", async () => { + it("CORS headers are absent for substring-matching origin (strict equality)", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ access_token: "ghu_tok", token_type: "bearer" }), { status: 200, diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index ae314c4d..11f1b477 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; -import { collectLogs, findLog } from "./helpers"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; // Base64-encoded test keys for testing (HKDF accepts any length input key material) // "test-session-key" base64-encoded @@ -327,6 +325,30 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("invalid_request"); }); + it("request with numeric token returns 400 with invalid_request", async () => { + // Turnstile verification runs before body parsing — mock needed for workerd fetch + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + const req = makeSealRequest({ body: { token: 42, purpose: "jira-api-token" } }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + + it("request with null token returns 400 with invalid_request", async () => { + // Turnstile verification runs before body parsing — mock needed for workerd fetch + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + const req = makeSealRequest({ body: { token: null, purpose: "jira-api-token" } }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + // ── OPTIONS preflight ───────────────────────────────────────────────────── it("OPTIONS preflight with valid origin returns 204 with correct CORS headers", async () => { @@ -373,6 +395,8 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(res.status).toBe(405); const json = await res.json() as Record; expect(json["error"]).toBe("method_not_allowed"); + // Session IS issued even on 405 responses (ensureSession runs before method check) + expect(res.headers.get("Set-Cookie")).toContain("__Host-session="); }); it("successful POST to /api/proxy/seal does not set Access-Control-Allow-Origin", async () => { @@ -439,7 +463,7 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(res.status).toBe(500); const json = await res.json() as Record; expect(json["error"]).toBe("seal_failed"); - // Must not include crypto error details in response (SC-9) + // Must not include crypto error details in response expect(JSON.stringify(json)).not.toContain("DOMException"); expect(JSON.stringify(json)).not.toContain("DataError"); }); @@ -459,7 +483,7 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); }); - // ── SC-11: seal operation logging ───────────────────────────────────────── + // ── Seal operation logging ─────────────────────────────────────────────── it("successful seal logs token_sealed event with purpose and token_length", async () => { globalThis.fetch = vi.fn().mockResolvedValue( diff --git a/tests/worker/sentry-tunnel.test.ts b/tests/worker/sentry-tunnel.test.ts index 42a653ab..d992bfb1 100644 --- a/tests/worker/sentry-tunnel.test.ts +++ b/tests/worker/sentry-tunnel.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; -import { collectLogs, findLog } from "./helpers"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; const SENTRY_HOST = "o123456.ingest.sentry.io"; const SENTRY_PROJECT_ID = "7890123"; const VALID_DSN = `https://abc123@${SENTRY_HOST}/${SENTRY_PROJECT_ID}`; @@ -455,4 +453,32 @@ describe("Sentry tunnel (/api/error-reporting)", () => { const corsLog = findLog(logs, "cors_origin_mismatch"); expect(corsLog).toBeUndefined(); }); + + // ── SENTRY_SECURITY_TOKEN forwarding ────────────────────────────────────── + + it("forwards X-Sentry-Token header when SENTRY_SECURITY_TOKEN is set", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = mockFetch; + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + await worker.fetch(req, makeEnv({ SENTRY_SECURITY_TOKEN: "my-sentry-secret" })); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Sentry-Token"]).toBe("my-sentry-secret"); + }); + + it("does not send X-Sentry-Token header when SENTRY_SECURITY_TOKEN is not set", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = mockFetch; + + const req = makeTunnelRequest(makeEnvelope(VALID_DSN)); + await worker.fetch(req, makeEnv({ SENTRY_SECURITY_TOKEN: undefined })); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Sentry-Token"]).toBeUndefined(); + }); }); diff --git a/tests/worker/validation.test.ts b/tests/worker/validation.test.ts index dff7644e..f23ed4fa 100644 --- a/tests/worker/validation.test.ts +++ b/tests/worker/validation.test.ts @@ -6,8 +6,7 @@ import { validateContentType, validateProxyRequest, } from "../../src/worker/validation"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { ALLOWED_ORIGIN } from "./helpers"; function makeRequest( options: { diff --git a/wrangler.toml b/wrangler.toml index 6a93eb25..94fa7f57 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -29,8 +29,8 @@ name = "PROXY_RATE_LIMITER" namespace_id = "1001" [ratelimits.simple] -limit = 60 -period = 60 +limit = 10 +period = 10 [observability] enabled = true From 44efe8718798dbd208924761bf8f4105284a4e9d Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 14 Apr 2026 15:58:52 -0400 Subject: [PATCH 51/56] fix(mcp): adds production origin to defaults Adds https://gh.gordoncode.dev to ALLOWED_ORIGINS_DEFAULT so the WebSocket relay works out of the box for deployed SPA users. MCP_RELAY_ALLOWED_ORIGINS env var remains for custom domains/forks. --- DEPLOY.md | 2 +- mcp/README.md | 2 +- mcp/src/ws-relay.ts | 7 ++++--- mcp/tests/ws-relay.test.ts | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 8517a398..8a957710 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -22,7 +22,7 @@ building. 5. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira/GitLab token sealing feature. 6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. -7. **MCP relay** — Set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server +7. **MCP relay** — If deploying to a custom domain, set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server (`https://gh.gordoncode.dev` is allowed by default) 8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. 9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain 10. **Security contact** — Update the email and scope domain in `SECURITY.md` diff --git a/mcp/README.md b/mcp/README.md index 31d4eb31..27c2da21 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -18,7 +18,7 @@ npm install -g github-tracker-mcp |----------|----------|---------|-------------| | `GITHUB_TOKEN` | No | — | Classic PAT with `repo` and `read:org` scopes (recommended), or fine-grained PAT with Actions (read), Contents (read), Issues (read), and Pull requests (read) permissions. Fine-grained PATs skip scope validation at startup. | | `MCP_WS_PORT` | No | `9876` | WebSocket relay port for receiving live data from the dashboard SPA. | -| `MCP_RELAY_ALLOWED_ORIGINS` | No | — | Comma-separated additional origins for WebSocket connections (e.g., `https://your-domain.example.com`). Localhost origins are always allowed. | +| `MCP_RELAY_ALLOWED_ORIGINS` | No | — | Comma-separated additional origins for WebSocket connections. Only needed if you deploy to a custom domain (the default `https://gh.gordoncode.dev` and localhost origins are always allowed). | `GITHUB_TOKEN` is required for standalone (direct API) mode. In relay mode the server receives data from the dashboard and works without a token. If you set `GITHUB_TOKEN` alongside the relay, the server uses it as a fallback when the relay disconnects. diff --git a/mcp/src/ws-relay.ts b/mcp/src/ws-relay.ts index bda3a222..83845922 100644 --- a/mcp/src/ws-relay.ts +++ b/mcp/src/ws-relay.ts @@ -44,6 +44,7 @@ const ALLOWED_ORIGINS_DEFAULT = new Set([ "https://localhost", "http://127.0.0.1", "https://127.0.0.1", + "https://gh.gordoncode.dev", ]); function buildAllowedOrigins(): Set { @@ -60,9 +61,9 @@ function buildAllowedOrigins(): Set { // Computed once at module scope — origins don't change at runtime const ALLOWED_ORIGINS = buildAllowedOrigins(); -// Warn if only localhost origins are configured — production domains need MCP_RELAY_ALLOWED_ORIGINS -if (!process.env.MCP_RELAY_ALLOWED_ORIGINS) { - console.warn("[mcp/ws] Warning: No MCP_RELAY_ALLOWED_ORIGINS set — only localhost connections allowed. Set this to your production domain to allow WebSocket relay from the deployed SPA."); +// Log extra origins if configured (useful for custom deployments / forks) +if (process.env.MCP_RELAY_ALLOWED_ORIGINS) { + console.error("[mcp/ws] Additional allowed origins:", process.env.MCP_RELAY_ALLOWED_ORIGINS); } function isOriginAllowed(origin: string | undefined): boolean { diff --git a/mcp/tests/ws-relay.test.ts b/mcp/tests/ws-relay.test.ts index 80621161..d1882bd9 100644 --- a/mcp/tests/ws-relay.test.ts +++ b/mcp/tests/ws-relay.test.ts @@ -407,6 +407,22 @@ describe("WebSocket relay server — origin validation", () => { expect(opened).toBe(true); }); + it("allows connections from the production domain", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { origin: "https://gh.gordoncode.dev" }, + }); + + const opened = await new Promise((resolve) => { + ws.once("open", () => { ws.close(); resolve(true); }); + ws.once("error", () => resolve(false)); + ws.once("close", (code) => { + if (code !== 1000 && code !== 1001) resolve(false); + }); + }); + + expect(opened).toBe(true); + }); + it("rejects connections from disallowed origins", async () => { // The server calls verifyClient with callback(false, 403, "Origin not allowed"). // The ws library sends an HTTP 403 response, which the client sees as an error. From 3c81d3b027e910ffa769530a3aac7a8b3a35c3b5 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 15 Apr 2026 13:29:12 -0400 Subject: [PATCH 52/56] fix(deploy): adds sync guard for validate-deploy.sh Adds a test that ensures validate-deploy.sh stays in sync with the TypeScript Env interfaces. Catches missing, stale, and unknown Worker secrets. Also warns about key rotation secrets (SEAL_KEY_PREV, SESSION_KEY_PREV) that were previously unchecked. --- scripts/validate-deploy.sh | 12 ++ tests/validate-deploy-sync.test.ts | 177 +++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/validate-deploy-sync.test.ts diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index a758dc50..7bd7104d 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -29,6 +29,18 @@ else done echo "$SECRETS" | grep -q '"name":"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" echo "$SECRETS" | grep -q '"name":"SENTRY_SECURITY_TOKEN"' || warn "CF Worker secret 'SENTRY_SECURITY_TOKEN' not set — only needed if Sentry Allowed Domains is configured" + echo "$SECRETS" | grep -q '"name":"SEAL_KEY_PREV"' || warn "CF Worker secret 'SEAL_KEY_PREV' not set — needed during key rotation" + echo "$SECRETS" | grep -q '"name":"SESSION_KEY_PREV"' || warn "CF Worker secret 'SESSION_KEY_PREV' not set — needed during key rotation" + + # Detect unexpected secrets not in the known set + KNOWN="ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY SENTRY_DSN SENTRY_SECURITY_TOKEN SEAL_KEY_PREV SESSION_KEY_PREV" + while IFS= read -r secret_name; do + found=false + for k in $KNOWN; do + [[ "$secret_name" == "$k" ]] && found=true && break + done + $found || warn "Unknown CF Worker secret '$secret_name' — not referenced by the app (stale?)" + done < <(echo "$SECRETS" | grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//') fi fi fi diff --git a/tests/validate-deploy-sync.test.ts b/tests/validate-deploy-sync.test.ts new file mode 100644 index 00000000..efb8671b --- /dev/null +++ b/tests/validate-deploy-sync.test.ts @@ -0,0 +1,177 @@ +// Ensures validate-deploy.sh stays in sync with the TypeScript Env interfaces. +// If a new Worker secret is added to an Env interface, this test fails until +// the deploy validation script is updated to check for it. + +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { describe, it, expect } from "vitest"; + +const ROOT = resolve(__dirname, ".."); + +function readFile(relPath: string): string { + return readFileSync(resolve(ROOT, relPath), "utf-8"); +} + +// ── Extract string fields from a TypeScript interface ──────────────────────── +// Matches lines like `FIELD_NAME: string;` or `FIELD_NAME?: string;` +// Skips non-string fields (CF bindings like ASSETS, PROXY_RATE_LIMITER). + +interface EnvField { + name: string; + optional: boolean; +} + +function extractInterfaceBody(source: string, interfaceName: string): string | null { + const headerRegex = new RegExp( + `(?:export\\s+)?interface\\s+${interfaceName}\\s*(?:extends[^{]*)?\\{`, + ); + const headerMatch = source.match(headerRegex); + if (!headerMatch) return null; + + // Walk from the opening brace, counting depth to find the matching close + const start = headerMatch.index! + headerMatch[0].length; + let depth = 1; + for (let i = start; i < source.length; i++) { + if (source[i] === "{") depth++; + if (source[i] === "}") depth--; + if (depth === 0) return source.slice(start, i); + } + return null; +} + +function extractStringFields(source: string, interfaceName: string): EnvField[] { + const body = extractInterfaceBody(source, interfaceName); + if (!body) return []; + + const fields: EnvField[] = []; + for (const line of body.split("\n")) { + // Match: FIELD_NAME?: string; or FIELD_NAME: string; + // Also handles inline comments: FIELD_NAME?: string; // comment + const fieldMatch = line.match(/^\s*(\w+)(\??):\s*string\s*;/); + if (fieldMatch) { + fields.push({ name: fieldMatch[1], optional: !!fieldMatch[2] }); + } + } + return fields; +} + +// ── Extract parent interfaces from `extends` clause ────────────────────────── + +function extractExtends(source: string, interfaceName: string): string[] { + const regex = new RegExp( + `(?:export\\s+)?interface\\s+${interfaceName}\\s+extends\\s+([^{]+)\\{`, + ); + const match = source.match(regex); + if (!match) return []; + return match[1].split(",").map((s) => s.trim()).filter(Boolean); +} + +// ── Extract checked variables from validate-deploy.sh ──────────────────────── + +interface ScriptChecks { + required: Set; + warned: Set; +} + +function extractScriptChecks(source: string): { + local: ScriptChecks; + ci: ScriptChecks; +} { + const local: ScriptChecks = { required: new Set(), warned: new Set() }; + const ci: ScriptChecks = { required: new Set(), warned: new Set() }; + + // Local mode — `for s in VAR1 VAR2 ...; do` + const forMatch = source.match(/for s in ([^;]+);/); + if (forMatch) { + for (const name of forMatch[1].trim().split(/\s+/)) { + local.required.add(name); + } + } + + // Local mode — `grep -q '"name":"VAR"' || warn ...` + for (const m of source.matchAll(/grep -q[^"]*"name":"(\w+)".*\|\|\s*warn/g)) { + local.warned.add(m[1]); + } + + // CI mode — `[[ -z "${VAR:-}" ]] && fail` + for (const m of source.matchAll(/\$\{(\w+):-\}.*&&\s*fail/g)) { + ci.required.add(m[1]); + } + + // CI mode — `[[ -z "${VAR:-}" ]] && warn` + for (const m of source.matchAll(/\$\{(\w+):-\}.*&&\s*warn/g)) { + ci.warned.add(m[1]); + } + + return { local, ci }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("validate-deploy.sh stays in sync with Env interfaces", () => { + // Map each Env interface to its source file + const envInterfaceFiles: Record = { + Env: "src/worker/index.ts", + CryptoEnv: "src/worker/crypto.ts", + SessionEnv: "src/worker/session.ts", + TurnstileEnv: "src/worker/turnstile.ts", + }; + + // Build the full field list from the Env interface hierarchy + const allFields: EnvField[] = []; + const envSource = readFile(envInterfaceFiles.Env); + allFields.push(...extractStringFields(envSource, "Env")); + + const parents = extractExtends(envSource, "Env"); + for (const parent of parents) { + const file = envInterfaceFiles[parent]; + if (!file) throw new Error(`Unknown parent interface: ${parent} — add it to envInterfaceFiles`); + allFields.push(...extractStringFields(readFile(file), parent)); + } + + const requiredFields = allFields.filter((f) => !f.optional).map((f) => f.name); + const optionalFields = allFields.filter((f) => f.optional).map((f) => f.name); + + const script = readFile("scripts/validate-deploy.sh"); + const checks = extractScriptChecks(script); + + it("every required Env field is checked as required in local mode", () => { + const missing = requiredFields.filter((f) => !checks.local.required.has(f)); + expect(missing, `Add these to the 'for s in ...' loop in validate-deploy.sh`).toEqual([]); + }); + + it("every optional Env field is warned about in local mode", () => { + const allChecked = new Set([...checks.local.required, ...checks.local.warned]); + const missing = optionalFields.filter((f) => !allChecked.has(f)); + expect(missing, `Add 'grep ... || warn' lines for these in validate-deploy.sh`).toEqual([]); + }); + + it("script doesn't check for fields removed from Env", () => { + const allFieldNames = new Set(allFields.map((f) => f.name)); + const allScriptVars = new Set([...checks.local.required, ...checks.local.warned]); + const stale = [...allScriptVars].filter((v) => !allFieldNames.has(v)); + expect(stale, `Remove these from validate-deploy.sh — they no longer exist in Env`).toEqual([]); + }); + + it("every VITE_ variable used in source is checked in CI mode", () => { + const viteFiles = [ + "src/app/lib/oauth.ts", + "src/app/lib/sentry.ts", + "src/app/lib/proxy.ts", + ]; + const viteVars = new Set(); + for (const file of viteFiles) { + for (const m of readFile(file).matchAll(/import\.meta\.env\.(VITE_\w+)/g)) { + viteVars.add(m[1]); + } + } + const allCiVars = new Set([...checks.ci.required, ...checks.ci.warned]); + const missing = [...viteVars].filter((v) => !allCiVars.has(v)); + expect(missing, `Add CI-mode checks for these VITE_ vars in validate-deploy.sh`).toEqual([]); + }); + + it("Env extends chain is fully covered", () => { + const uncovered = parents.filter((p) => !envInterfaceFiles[p]); + expect(uncovered, `Add these to envInterfaceFiles in this test`).toEqual([]); + }); +}); From 88043c1f83435ae6c0f7f37bcb7b5786ecfb2117 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 15 Apr 2026 13:32:27 -0400 Subject: [PATCH 53/56] docs(deploy): restructure checklist into OAuth and static-only paths Move static-only deployment section above OAuth setup to present both paths as equal choices. Replace 'Required' heading with 'OAuth + Cloudflare Worker' to clarify the backend is only needed for OAuth. Tag Worker-specific optional items so static deployers can skip them. --- DEPLOY.md | 55 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 8a957710..632127dc 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -2,34 +2,10 @@ ## Fork Deployment Checklist -If you're deploying your own instance of GitHub Tracker, update these values: +If you're deploying your own instance of GitHub Tracker, choose a deployment path: -### Required (deployment won't work without these) - -1. **Create a GitHub OAuth App** — [Settings → Developer settings → OAuth Apps](https://github.com/settings/developers) - - Set the callback URL to `https://YOUR-DOMAIN/oauth/callback` -2. **Update `wrangler.toml`** — Change `pattern = "gh.gordoncode.dev"` to your domain -3. **Set GitHub Actions secrets and variables** — See sections below -4. **Set Cloudflare Worker secrets** — See "Cloudflare Worker Secrets" section below. **Critical:** `ALLOWED_ORIGIN` must exactly match your deployment URL (e.g., `https://your-domain.example.com`). An incorrect value causes all API requests to fail with CORS errors. - -### Verify configuration - -Run `pnpm validate:deploy` locally to check that all required Cloudflare Worker secrets -are set. In CI, the deploy workflow runs `pnpm validate:deploy --ci` automatically before -building. - -### Optional - -5. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira/GitLab token sealing feature. -6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. -7. **MCP relay** — If deploying to a custom domain, set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server (`https://gh.gordoncode.dev` is allowed by default) -8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. -9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain -10. **Security contact** — Update the email and scope domain in `SECURITY.md` -11. **README** — Update the "Live demo" URL in `README.md` -12. **User Guide** — Update the domain reference in `docs/USER_GUIDE.md` -13. **App footer links** — Update "Source" and "Guide" URLs in `src/app/components/dashboard/DashboardPage.tsx` and `src/app/components/settings/SettingsPage.tsx` if you want them to point to your fork -14. **Contributing guide** — Update the clone URL and PR target in `CONTRIBUTING.md` +- **OAuth + Cloudflare Worker** — "Sign in with GitHub" button, requires a backend for the OAuth client secret exchange +- **Static-only (PAT)** — Host anywhere, no backend needed, users paste a Personal Access Token ### Static-only deployment (no Cloudflare Worker) @@ -81,6 +57,31 @@ No `VITE_GITHUB_CLIENT_ID` or `VITE_TURNSTILE_SITE_KEY` is needed for PAT-only deployments — leave them empty. OAuth login won't work without a client ID (use PAT instead), and Turnstile is only used by the planned Jira/GitLab integration. +### OAuth + Cloudflare Worker + +1. **Create a GitHub OAuth App** — [Settings → Developer settings → OAuth Apps](https://github.com/settings/developers) + - Set the callback URL to `https://YOUR-DOMAIN/oauth/callback` +2. **Update `wrangler.toml`** — Change `pattern = "gh.gordoncode.dev"` to your domain +3. **Set GitHub Actions secrets and variables** — See sections below +4. **Set Cloudflare Worker secrets** — See "Cloudflare Worker Secrets" section below. **Critical:** `ALLOWED_ORIGIN` must exactly match your deployment URL (e.g., `https://your-domain.example.com`). An incorrect value causes all API requests to fail with CORS errors. + +**Verify configuration:** Run `pnpm validate:deploy` locally to check that all required +Cloudflare Worker secrets are set. In CI, the deploy workflow runs +`pnpm validate:deploy --ci` automatically before building. + +### Optional (both deployment paths) + +5. **MCP relay** — If deploying to a custom domain, set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server (`https://gh.gordoncode.dev` is allowed by default) +6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. **Worker-only** — does not work on static deploys. +7. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira/GitLab token sealing feature. **Worker-only.** +8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. **Worker-only.** +9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain +10. **Security contact** — Update the email and scope domain in `SECURITY.md` +11. **README** — Update the "Live demo" URL in `README.md` +12. **User Guide** — Update the domain reference in `docs/USER_GUIDE.md` +13. **App footer links** — Update "Source" and "Guide" URLs in `src/app/components/dashboard/DashboardPage.tsx` and `src/app/components/settings/SettingsPage.tsx` if you want them to point to your fork +14. **Contributing guide** — Update the clone URL and PR target in `CONTRIBUTING.md` + ## GitHub Actions Secrets and Variables ### Secrets (GitHub repo → Settings → Secrets and variables → Actions → Secrets) From 875d131b38fed6a15d5b84fe04dd1ca12784110b Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 15 Apr 2026 13:37:33 -0400 Subject: [PATCH 54/56] docs(deploy): removes GitLab references --- DEPLOY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 632127dc..b7cbe6ad 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -40,7 +40,7 @@ routing. `report-uri /api/csp-report` directive in `public/_headers` will produce harmless 404 console errors on static hosts. Optionally remove the `report-uri` and `report-to` directives if the noise is unwanted. -- **Jira/GitLab token sealing** (planned) — requires server-side encryption +- **Jira token sealing** (planned) — requires server-side encryption **Security note:** The `public/_headers` file sets Content-Security-Policy and other security headers. Ensure your static host serves these headers — Cloudflare Pages, @@ -55,7 +55,7 @@ pnpm run build # Output in dist/ ``` No `VITE_GITHUB_CLIENT_ID` or `VITE_TURNSTILE_SITE_KEY` is needed for PAT-only deployments — leave them empty. OAuth login won't work without a client ID (use -PAT instead), and Turnstile is only used by the planned Jira/GitLab integration. +PAT instead), and Turnstile is only used by the planned Jira integration. ### OAuth + Cloudflare Worker @@ -73,7 +73,7 @@ Cloudflare Worker secrets are set. In CI, the deploy workflow runs 5. **MCP relay** — If deploying to a custom domain, set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server (`https://gh.gordoncode.dev` is allowed by default) 6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. **Worker-only** — does not work on static deploys. -7. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira/GitLab token sealing feature. **Worker-only.** +7. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira token sealing feature. **Worker-only.** 8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. **Worker-only.** 9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain 10. **Security contact** — Update the email and scope domain in `SECURITY.md` From 4abde8e9a83d0cc82c4d2482d5f35f2e8bc2ef36 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 15 Apr 2026 15:17:09 -0400 Subject: [PATCH 55/56] fix(deploy): unify validate-deploy, rename key rotation to _NEXT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-deploy.sh: - Remove --ci flag; single path checks VITE_ vars + wrangler secrets - Resolve wrangler via node_modules/.bin (not just global PATH) - Use --format json (wrangler v3 dropped --json flag) - Fix grep patterns for pretty-printed JSON (whitespace-tolerant) - Check .env files as fallback for VITE_ vars (Vite loads at build) Key rotation (_PREV → _NEXT): - Rename SESSION_KEY_PREV/SEAL_KEY_PREV to _NEXT variants - Sign/seal with NEXT key during rotation, verify/unseal with both - Fixes operational flow: _PREV required knowing the current secret value, which is impossible to retrieve from Cloudflare (write-only) - _NEXT only requires the value you just generated --- .github/workflows/deploy.yml | 2 +- DEPLOY.md | 26 ++++----- scripts/validate-deploy.sh | 87 ++++++++++++++++++------------ src/worker/crypto.ts | 16 +++--- src/worker/session.ts | 29 +++++----- tests/validate-deploy-sync.test.ts | 52 +++++++++--------- tests/worker/session.test.ts | 38 +++++++++---- 7 files changed, 150 insertions(+), 100 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22edac83..3d91a476 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,7 @@ jobs: if: vars.DEPLOY_DOMAIN != '' run: pnpm test:waf "https://${{ vars.DEPLOY_DOMAIN }}" - name: Validate deploy configuration - run: pnpm validate:deploy --ci + run: pnpm validate:deploy env: VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} diff --git a/DEPLOY.md b/DEPLOY.md index b7cbe6ad..cf3da5e3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -289,26 +289,28 @@ wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard ### First deployment -On initial deployment, set only `SESSION_KEY`, `SEAL_KEY`, and `TURNSTILE_SECRET_KEY`. Do **not** set `SESSION_KEY_PREV` or `SEAL_KEY_PREV` — these are only needed during key rotation after the initial keys are in use. +On initial deployment, set only `SESSION_KEY`, `SEAL_KEY`, and `TURNSTILE_SECRET_KEY`. Do **not** set `SESSION_KEY_NEXT` or `SEAL_KEY_NEXT` — these are only needed during key rotation. ### Key rotation To rotate a key without invalidating existing sessions/tokens: -1. Set the `*_PREV` secret to the **current** key value: +1. Generate the new key and set it as `*_NEXT`: ```bash - wrangler secret put SESSION_KEY_PREV # Copy current SESSION_KEY value here first - wrangler secret put SEAL_KEY_PREV # Copy current SEAL_KEY value here first + openssl rand -base64 32 # generate new value, save it + wrangler secret put SESSION_KEY_NEXT # paste the new value + wrangler secret put SEAL_KEY_NEXT # paste the new value ``` -2. Generate a new key and update the main secret: +2. The Worker immediately starts signing new sessions and sealing new tokens with the `*_NEXT` key, while still accepting the current key for verification/unsealing. +3. After all clients have cycled (sessions expire after 8 hours), promote the new key: ```bash - openssl rand -base64 32 # generate new value - wrangler secret put SESSION_KEY # update with new value - wrangler secret put SEAL_KEY # update with new value + wrangler secret put SESSION_KEY # paste the same new value from step 1 + wrangler secret put SEAL_KEY # paste the same new value from step 1 ``` -3. The Worker will accept tokens signed/sealed with either the current or previous key during the transition window. -4. After all clients have cycled (sessions expire after 8 hours), optionally remove `*_PREV`: +4. Remove the `*_NEXT` secrets: ```bash - wrangler secret delete SESSION_KEY_PREV - wrangler secret delete SEAL_KEY_PREV + wrangler secret delete SESSION_KEY_NEXT + wrangler secret delete SEAL_KEY_NEXT ``` + +**Why `_NEXT` instead of `_PREV`?** Cloudflare Worker secrets are write-only — you cannot read back a secret's value. A `_PREV` design requires knowing the current key value to copy it, which is impossible to retrieve. With `_NEXT`, you only need the value you just generated (which you still have in your clipboard). diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index 7bd7104d..2b748c5f 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -1,47 +1,68 @@ #!/usr/bin/env bash -# Usage: pnpm validate:deploy [--ci] -# Local: checks CF Worker secrets via wrangler secret list -# CI: checks build-time env vars and deploy credentials +# Usage: pnpm validate:deploy +# Checks build-time env vars (VITE_*) and CF Worker secrets via wrangler. +# Wrangler authenticates via CLOUDFLARE_API_TOKEN env var (CI) or interactive login (local). # SECURITY: This script must NEVER echo, log, or display secret values. set -euo pipefail -CI_MODE=false -[[ "${1:-}" == "--ci" ]] && CI_MODE=true ERRORS=0 warn() { printf '[WARN] %s\n' "$1" >&2; } fail() { printf '[FAIL] %s\n' "$1" >&2; ERRORS=$((ERRORS+1)); } -if $CI_MODE; then - [[ -z "${VITE_GITHUB_CLIENT_ID:-}" ]] && fail "VITE_GITHUB_CLIENT_ID not set (add as GitHub Actions variable)" - [[ -z "${VITE_SENTRY_DSN:-}" ]] && warn "VITE_SENTRY_DSN not set — Sentry disabled in this build" - [[ -z "${VITE_TURNSTILE_SITE_KEY:-}" ]] && warn "VITE_TURNSTILE_SITE_KEY not set — Turnstile disabled" - [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]] && fail "CLOUDFLARE_API_TOKEN not set (add as GitHub Actions secret)" - [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]] && fail "CLOUDFLARE_ACCOUNT_ID not set (add as GitHub Actions secret)" +# ── Resolve wrangler binary ───────────────────────────────────────────────── +resolve_wrangler() { + if command -v wrangler &>/dev/null; then + printf 'wrangler' + elif [[ -x "./node_modules/.bin/wrangler" ]]; then + printf './node_modules/.bin/wrangler' + else + return 1 + fi +} + +# ── Check a VITE_ var: shell env first, then .env / .env.local files ──────── +check_vite_var() { + local var_name="$1" level="$2" msg="$3" + # Already in shell environment (CI passes them as env:) + if [[ -n "${!var_name:-}" ]]; then return 0; fi + # Check .env files (Vite loads these at build time) + for f in .env .env.local .env.production .env.production.local; do + if [[ -f "$f" ]] && grep -q "^${var_name}=" "$f"; then return 0; fi + done + "$level" "$msg" +} + +# ── Build-time env vars (VITE_*) ──────────────────────────────────────────── +check_vite_var VITE_GITHUB_CLIENT_ID fail "VITE_GITHUB_CLIENT_ID not set (GitHub Actions variable or .env)" +check_vite_var VITE_SENTRY_DSN warn "VITE_SENTRY_DSN not set — Sentry disabled in this build" +check_vite_var VITE_TURNSTILE_SITE_KEY warn "VITE_TURNSTILE_SITE_KEY not set — Turnstile disabled" + +# ── CF Worker secrets via wrangler ────────────────────────────────────────── +if ! WRANGLER=$(resolve_wrangler); then + fail "wrangler CLI not found — install with: pnpm add -D wrangler" else - if ! command -v wrangler &>/dev/null; then - fail "wrangler CLI not found — install with: pnpm add -g wrangler" + if ! SECRETS=$($WRANGLER secret list --format json 2>&1); then + fail "wrangler secret list failed — run: wrangler login (or set CLOUDFLARE_API_TOKEN)" else - if ! SECRETS=$(wrangler secret list --json 2>&1); then - fail "wrangler secret list failed — run: wrangler login" - else - for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY; do - echo "$SECRETS" | grep -q "\"name\":\"$s\"" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" + has_secret() { echo "$SECRETS" | grep -q "\"name\"[[:space:]]*:[[:space:]]*\"$1\""; } + + for s in ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY; do + has_secret "$s" || fail "CF Worker secret '$s' not set (run: wrangler secret put $s)" + done + has_secret SENTRY_DSN || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" + has_secret SENTRY_SECURITY_TOKEN || warn "CF Worker secret 'SENTRY_SECURITY_TOKEN' not set — only needed if Sentry Allowed Domains is configured" + has_secret SEAL_KEY_NEXT || warn "CF Worker secret 'SEAL_KEY_NEXT' not set — only needed during key rotation" + has_secret SESSION_KEY_NEXT || warn "CF Worker secret 'SESSION_KEY_NEXT' not set — only needed during key rotation" + + # Detect unexpected secrets not in the known set + KNOWN="ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY SENTRY_DSN SENTRY_SECURITY_TOKEN SEAL_KEY_NEXT SESSION_KEY_NEXT" + while IFS= read -r secret_name; do + found=false + for k in $KNOWN; do + [[ "$secret_name" == "$k" ]] && found=true && break done - echo "$SECRETS" | grep -q '"name":"SENTRY_DSN"' || warn "CF Worker secret 'SENTRY_DSN' not set — Sentry error tunnel returns 404" - echo "$SECRETS" | grep -q '"name":"SENTRY_SECURITY_TOKEN"' || warn "CF Worker secret 'SENTRY_SECURITY_TOKEN' not set — only needed if Sentry Allowed Domains is configured" - echo "$SECRETS" | grep -q '"name":"SEAL_KEY_PREV"' || warn "CF Worker secret 'SEAL_KEY_PREV' not set — needed during key rotation" - echo "$SECRETS" | grep -q '"name":"SESSION_KEY_PREV"' || warn "CF Worker secret 'SESSION_KEY_PREV' not set — needed during key rotation" - - # Detect unexpected secrets not in the known set - KNOWN="ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY SENTRY_DSN SENTRY_SECURITY_TOKEN SEAL_KEY_PREV SESSION_KEY_PREV" - while IFS= read -r secret_name; do - found=false - for k in $KNOWN; do - [[ "$secret_name" == "$k" ]] && found=true && break - done - $found || warn "Unknown CF Worker secret '$secret_name' — not referenced by the app (stale?)" - done < <(echo "$SECRETS" | grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//') - fi + $found || warn "Unknown CF Worker secret '$secret_name' — not referenced by the app (stale?)" + done < <(echo "$SECRETS" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"//') fi fi diff --git a/src/worker/crypto.ts b/src/worker/crypto.ts index a980c299..ddbd72e9 100644 --- a/src/worker/crypto.ts +++ b/src/worker/crypto.ts @@ -1,6 +1,6 @@ export interface CryptoEnv { SEAL_KEY: string; // base64-encoded HKDF input key material (32 bytes recommended) - SEAL_KEY_PREV?: string; // previous HKDF key material for rotation + SEAL_KEY_NEXT?: string; // next HKDF key material for rotation (set before promoting to SEAL_KEY) } // ── Base64url utilities ──────────────────────────────────────────────────── @@ -136,14 +136,18 @@ export async function unsealToken( } /** - * Unseals a token, falling back to prevKey if currentKey fails. + * Unseals a token, trying both current and next keys during rotation. * Both salt and info must match the values used during sealing. * The info parameter MUST include a purpose string for token audience binding. + * + * During rotation, tokens may have been sealed with either the current key + * (SEAL_KEY) or the next key (SEAL_KEY_NEXT). Try current first since most + * tokens were sealed before rotation began. */ export async function unsealTokenWithRotation( sealed: string, currentKey: string, - prevKey: string | undefined, + nextKey: string | undefined, salt: string, info: string ): Promise { @@ -151,9 +155,9 @@ export async function unsealTokenWithRotation( const result = await unsealToken(sealed, current); if (result !== null) return result; - if (prevKey !== undefined) { - const prev = await deriveKey(prevKey, salt, info, "encrypt"); - return unsealToken(sealed, prev); + if (nextKey !== undefined) { + const next = await deriveKey(nextKey, salt, info, "encrypt"); + return unsealToken(sealed, next); } return null; diff --git a/src/worker/session.ts b/src/worker/session.ts index 82971ccf..502739c3 100644 --- a/src/worker/session.ts +++ b/src/worker/session.ts @@ -20,7 +20,7 @@ import { export interface SessionEnv { SESSION_KEY: string; - SESSION_KEY_PREV?: string; + SESSION_KEY_NEXT?: string; } export interface SessionPayload { @@ -34,25 +34,25 @@ const SESSION_HMAC_SALT = "github-tracker-session-v1"; const SESSION_HMAC_INFO = "session-hmac"; const SESSION_MAX_AGE = 28800; // 8 hours in seconds -// Module-level cache for derived session HMAC keys, keyed by purpose ("current" | "prev"). +// Module-level cache for derived session HMAC keys, keyed by slot ("current" | "next"). // Invalidated on SESSION_KEY rotation via compound fingerprint comparison. const _sessionKeyCache = new Map(); let _sessionKeyFingerprint = ""; async function getSessionHmacKey( env: SessionEnv, - purpose: "current" | "prev" + slot: "current" | "next" ): Promise { - const raw = purpose === "current" ? env.SESSION_KEY : env.SESSION_KEY_PREV!; - const fp = `${env.SESSION_KEY}:${env.SESSION_KEY_PREV ?? ""}`; + const raw = slot === "current" ? env.SESSION_KEY : env.SESSION_KEY_NEXT!; + const fp = `${env.SESSION_KEY}:${env.SESSION_KEY_NEXT ?? ""}`; if (fp !== _sessionKeyFingerprint) { _sessionKeyCache.clear(); _sessionKeyFingerprint = fp; } - const cached = _sessionKeyCache.get(purpose); + const cached = _sessionKeyCache.get(slot); if (cached !== undefined) return cached; const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); - _sessionKeyCache.set(purpose, key); + _sessionKeyCache.set(slot, key); return key; } @@ -71,7 +71,9 @@ export async function issueSession( }; const json = JSON.stringify(payload); - const hmacKey = await getSessionHmacKey(env, "current"); + // Sign with NEXT key if rotation is in progress, otherwise current + const signingSlot = env.SESSION_KEY_NEXT !== undefined ? "next" : "current"; + const hmacKey = await getSessionHmacKey(env, signingSlot); const signature = await signSession(json, hmacKey); // base64url(JSON(payload)).base64url(HMAC-SHA256(JSON(payload))) @@ -112,12 +114,15 @@ export async function parseSession( const json = new TextDecoder().decode(fromBase64Url(encodedPayload)); const payload = JSON.parse(json) as SessionPayload; - // Verify HMAC signature (rotation-aware, using cached derived keys) + // Verify HMAC signature (rotation-aware, using cached derived keys). + // During rotation, sessions may be signed with either key: + // - current (SESSION_KEY): pre-rotation sessions + // - next (SESSION_KEY_NEXT): sessions issued after rotation started const currentKey = await getSessionHmacKey(env, "current"); let valid = await verifySession(json, signature, currentKey); - if (!valid && env.SESSION_KEY_PREV !== undefined) { - const prevKey = await getSessionHmacKey(env, "prev"); - valid = await verifySession(json, signature, prevKey); + if (!valid && env.SESSION_KEY_NEXT !== undefined) { + const nextKey = await getSessionHmacKey(env, "next"); + valid = await verifySession(json, signature, nextKey); } if (!valid) return null; diff --git a/tests/validate-deploy-sync.test.ts b/tests/validate-deploy-sync.test.ts index efb8671b..da9af9e7 100644 --- a/tests/validate-deploy-sync.test.ts +++ b/tests/validate-deploy-sync.test.ts @@ -74,36 +74,36 @@ interface ScriptChecks { } function extractScriptChecks(source: string): { - local: ScriptChecks; - ci: ScriptChecks; + secrets: ScriptChecks; + viteVars: ScriptChecks; } { - const local: ScriptChecks = { required: new Set(), warned: new Set() }; - const ci: ScriptChecks = { required: new Set(), warned: new Set() }; + const secrets: ScriptChecks = { required: new Set(), warned: new Set() }; + const viteVars: ScriptChecks = { required: new Set(), warned: new Set() }; - // Local mode — `for s in VAR1 VAR2 ...; do` + // Worker secrets — `for s in VAR1 VAR2 ...; do` const forMatch = source.match(/for s in ([^;]+);/); if (forMatch) { for (const name of forMatch[1].trim().split(/\s+/)) { - local.required.add(name); + secrets.required.add(name); } } - // Local mode — `grep -q '"name":"VAR"' || warn ...` - for (const m of source.matchAll(/grep -q[^"]*"name":"(\w+)".*\|\|\s*warn/g)) { - local.warned.add(m[1]); + // Worker secrets — `has_secret VAR || warn ...` + for (const m of source.matchAll(/has_secret\s+(\w+)\s*\|\|\s*warn/g)) { + secrets.warned.add(m[1]); } - // CI mode — `[[ -z "${VAR:-}" ]] && fail` - for (const m of source.matchAll(/\$\{(\w+):-\}.*&&\s*fail/g)) { - ci.required.add(m[1]); + // VITE_ vars — `check_vite_var VAR fail "..."` + for (const m of source.matchAll(/check_vite_var\s+(VITE_\w+)\s+fail\b/g)) { + viteVars.required.add(m[1]); } - // CI mode — `[[ -z "${VAR:-}" ]] && warn` - for (const m of source.matchAll(/\$\{(\w+):-\}.*&&\s*warn/g)) { - ci.warned.add(m[1]); + // VITE_ vars — `check_vite_var VAR warn "..."` + for (const m of source.matchAll(/check_vite_var\s+(VITE_\w+)\s+warn\b/g)) { + viteVars.warned.add(m[1]); } - return { local, ci }; + return { secrets, viteVars }; } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -135,25 +135,25 @@ describe("validate-deploy.sh stays in sync with Env interfaces", () => { const script = readFile("scripts/validate-deploy.sh"); const checks = extractScriptChecks(script); - it("every required Env field is checked as required in local mode", () => { - const missing = requiredFields.filter((f) => !checks.local.required.has(f)); + it("every required Env field is checked as required", () => { + const missing = requiredFields.filter((f) => !checks.secrets.required.has(f)); expect(missing, `Add these to the 'for s in ...' loop in validate-deploy.sh`).toEqual([]); }); - it("every optional Env field is warned about in local mode", () => { - const allChecked = new Set([...checks.local.required, ...checks.local.warned]); + it("every optional Env field is warned about", () => { + const allChecked = new Set([...checks.secrets.required, ...checks.secrets.warned]); const missing = optionalFields.filter((f) => !allChecked.has(f)); - expect(missing, `Add 'grep ... || warn' lines for these in validate-deploy.sh`).toEqual([]); + expect(missing, `Add 'has_secret VAR || warn' lines for these in validate-deploy.sh`).toEqual([]); }); it("script doesn't check for fields removed from Env", () => { const allFieldNames = new Set(allFields.map((f) => f.name)); - const allScriptVars = new Set([...checks.local.required, ...checks.local.warned]); + const allScriptVars = new Set([...checks.secrets.required, ...checks.secrets.warned]); const stale = [...allScriptVars].filter((v) => !allFieldNames.has(v)); expect(stale, `Remove these from validate-deploy.sh — they no longer exist in Env`).toEqual([]); }); - it("every VITE_ variable used in source is checked in CI mode", () => { + it("every VITE_ variable used in source is checked", () => { const viteFiles = [ "src/app/lib/oauth.ts", "src/app/lib/sentry.ts", @@ -165,9 +165,9 @@ describe("validate-deploy.sh stays in sync with Env interfaces", () => { viteVars.add(m[1]); } } - const allCiVars = new Set([...checks.ci.required, ...checks.ci.warned]); - const missing = [...viteVars].filter((v) => !allCiVars.has(v)); - expect(missing, `Add CI-mode checks for these VITE_ vars in validate-deploy.sh`).toEqual([]); + const allScriptViteVars = new Set([...checks.viteVars.required, ...checks.viteVars.warned]); + const missing = [...viteVars].filter((v) => !allScriptViteVars.has(v)); + expect(missing, `Add checks for these VITE_ vars in validate-deploy.sh`).toEqual([]); }); it("Env extends chain is fully covered", () => { diff --git a/tests/worker/session.test.ts b/tests/worker/session.test.ts index 6debf210..ee06b1e3 100644 --- a/tests/worker/session.test.ts +++ b/tests/worker/session.test.ts @@ -164,29 +164,47 @@ describe("parseSession", () => { expect(parsed!.sid).toBe(sessionId); }); - it("signature rotation: signed with old key, verified with new+old", async () => { - const envOld = makeEnv({ SESSION_KEY: KEY_A }); - const { cookie } = await issueSession(envOld); + it("rotation: session signed with current key, verified after NEXT is set", async () => { + // Session issued before rotation (signed with KEY_A) + const envBefore = makeEnv({ SESSION_KEY: KEY_A }); + const { cookie } = await issueSession(envBefore); const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); - // Now verify with KEY_B as current, KEY_A as prev - const envNew = makeEnv({ - SESSION_KEY: KEY_B, - SESSION_KEY_PREV: KEY_A, + // Rotation starts: KEY_A still current, KEY_B is next + const envDuring = makeEnv({ + SESSION_KEY: KEY_A, + SESSION_KEY_NEXT: KEY_B, }); const result = await parseSession( `__Host-session=${cookieValue}`, - envNew + envDuring + ); + expect(result).not.toBeNull(); + }); + + it("rotation: session signed with NEXT key, verified with both", async () => { + // Rotation in progress: new sessions signed with NEXT + const envDuring = makeEnv({ + SESSION_KEY: KEY_A, + SESSION_KEY_NEXT: KEY_B, + }); + const { cookie } = await issueSession(envDuring); + const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); + + // Verify with same rotation env — should find via NEXT key + const result = await parseSession( + `__Host-session=${cookieValue}`, + envDuring ); expect(result).not.toBeNull(); }); - it("returns null when old key is not in rotation", async () => { + it("returns null when key is not in rotation set", async () => { const envOld = makeEnv({ SESSION_KEY: KEY_A }); const { cookie } = await issueSession(envOld); const cookieValue = cookie.split(";")[0].split("=").slice(1).join("="); - // KEY_B only, no KEY_A in rotation + // KEY_B only, KEY_A not in rotation const envNew = makeEnv({ SESSION_KEY: KEY_B }); const result = await parseSession( `__Host-session=${cookieValue}`, From df0a5b5d43daa53e26954c0a380870256ceb9b6d Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 15 Apr 2026 15:22:46 -0400 Subject: [PATCH 56/56] docs(deploy): separate session and seal key rotation lifecycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session keys rotate in 8 hours (cookie Max-Age). Seal keys protect localStorage tokens with no expiry — promoting SEAL_KEY before all clients re-seal makes old tokens permanently unreadable. --- DEPLOY.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index cf3da5e3..c34fdfd3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -293,24 +293,30 @@ On initial deployment, set only `SESSION_KEY`, `SEAL_KEY`, and `TURNSTILE_SECRET ### Key rotation -To rotate a key without invalidating existing sessions/tokens: +Session keys and seal keys have different rotation lifecycles because their tokens have different lifetimes. -1. Generate the new key and set it as `*_NEXT`: +**Session key** (`SESSION_KEY`) — sessions expire after 8 hours (`__Host-session` cookie `Max-Age`), so the transition window is short: + +1. Generate a new key and set it as `SESSION_KEY_NEXT`: ```bash - openssl rand -base64 32 # generate new value, save it - wrangler secret put SESSION_KEY_NEXT # paste the new value - wrangler secret put SEAL_KEY_NEXT # paste the new value + openssl rand -base64 32 # save this value + wrangler secret put SESSION_KEY_NEXT ``` -2. The Worker immediately starts signing new sessions and sealing new tokens with the `*_NEXT` key, while still accepting the current key for verification/unsealing. -3. After all clients have cycled (sessions expire after 8 hours), promote the new key: +2. The Worker signs new sessions with `SESSION_KEY_NEXT` and verifies with both keys. +3. After 8 hours (all old sessions expired), promote and clean up: ```bash - wrangler secret put SESSION_KEY # paste the same new value from step 1 - wrangler secret put SEAL_KEY # paste the same new value from step 1 + wrangler secret put SESSION_KEY # paste the same value from step 1 + wrangler secret delete SESSION_KEY_NEXT ``` -4. Remove the `*_NEXT` secrets: + +**Seal key** (`SEAL_KEY`) — sealed tokens (e.g., Jira API tokens) are stored in the client's `localStorage` with no expiry. They persist until the user re-seals or clears storage: + +1. Generate a new key and set it as `SEAL_KEY_NEXT`: ```bash - wrangler secret delete SESSION_KEY_NEXT - wrangler secret delete SEAL_KEY_NEXT + openssl rand -base64 32 # save this value + wrangler secret put SEAL_KEY_NEXT ``` +2. The Worker seals new tokens with `SEAL_KEY_NEXT` and unseals with both keys. +3. **Do not promote until all clients have re-sealed their tokens.** Promoting `SEAL_KEY` and deleting `SEAL_KEY_NEXT` makes tokens sealed with the old key permanently unreadable. If you cannot ensure all clients have re-sealed, keep `SEAL_KEY_NEXT` set indefinitely — the Worker handles both keys with no performance penalty. -**Why `_NEXT` instead of `_PREV`?** Cloudflare Worker secrets are write-only — you cannot read back a secret's value. A `_PREV` design requires knowing the current key value to copy it, which is impossible to retrieve. With `_NEXT`, you only need the value you just generated (which you still have in your clipboard). +**Why `_NEXT` instead of `_PREV`?** Cloudflare Worker secrets are write-only — you cannot read back a secret's value. A `_PREV` design requires knowing the current key value to copy it, which is impossible to retrieve. With `_NEXT`, you only need the value you just generated.