diff --git a/.dev.vars.example b/.dev.vars.example index acf7c571..b2ba5081 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,8 @@ 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 +# Optional: only needed if Sentry "Allowed Domains" is configured in your Sentry project settings +# SENTRY_SECURITY_TOKEN=your-sentry-security-token diff --git a/.env.example b/.env.example index 9dc07a3a..eb5c8139 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,15 @@ 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 + +# ── 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..3d91a476 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,10 +21,21 @@ 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 }}" + - name: Validate deploy configuration + run: pnpm validate:deploy + 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 }} + 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/DEPLOY.md b/DEPLOY.md index 11d63cfa..c34fdfd3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,5 +1,87 @@ # Deployment Guide +## Fork Deployment Checklist + +If you're deploying your own instance of GitHub Tracker, choose a deployment path: + +- **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) + +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 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 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 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) @@ -29,8 +111,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 +149,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 @@ -75,6 +157,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 @@ -89,12 +172,32 @@ 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 + +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 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. + +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 @@ -114,3 +217,106 @@ 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://YOUR-DOMAIN"})) 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` 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. +- 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 + +See `hack/docs/security-runbook.md` for implementation details. + +--- + +## 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 # 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`: 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). + +### First deployment + +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 + +Session keys and seal keys have different rotation lifecycles because their tokens have different lifetimes. + +**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 # save this value + wrangler secret put SESSION_KEY_NEXT + ``` +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 value from step 1 + wrangler secret delete SESSION_KEY_NEXT + ``` + +**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 + 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. 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/mcp/README.md b/mcp/README.md index 44b8cec2..27c2da21 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. 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 049a68ff..83845922 100644 --- a/mcp/src/ws-relay.ts +++ b/mcp/src/ws-relay.ts @@ -61,6 +61,11 @@ function buildAllowedOrigins(): Set { // Computed once at module scope — origins don't change at runtime const ALLOWED_ORIGINS = buildAllowedOrigins(); +// 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 { // Non-browser clients (e.g. CLI tools) do not send Origin — allow them. if (origin === undefined) return true; 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. diff --git a/package.json b/package.json index 178a0bed..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", @@ -23,6 +24,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", @@ -33,6 +35,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..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,13 +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(@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 @@ -83,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 @@ -126,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: @@ -451,6 +457,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==} @@ -715,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==} @@ -988,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'} @@ -1576,6 +1600,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 +2018,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'} @@ -2464,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: @@ -2606,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: @@ -2739,6 +2770,11 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@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(@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: '@floating-ui/utils': 0.2.11 @@ -2994,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': @@ -3162,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)': @@ -3750,6 +3793,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 +4133,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 @@ -4458,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)) @@ -4481,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)) @@ -4509,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/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/scripts/validate-deploy.sh b/scripts/validate-deploy.sh new file mode 100755 index 00000000..2b748c5f --- /dev/null +++ b/scripts/validate-deploy.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# 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 + +ERRORS=0 +warn() { printf '[WARN] %s\n' "$1" >&2; } +fail() { printf '[FAIL] %s\n' "$1" >&2; ERRORS=$((ERRORS+1)); } + +# ── 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 ! SECRETS=$($WRANGLER secret list --format json 2>&1); then + fail "wrangler secret list failed — run: wrangler login (or set CLOUDFLARE_API_TOKEN)" + else + 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 + $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 + +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 diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh index 1524b50f..001b3bfa 100755 --- a/scripts/waf-smoke-test.sh +++ b/scripts/waf-smoke-test.sh @@ -1,8 +1,9 @@ #!/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 +# 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/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() { } /> - + ); } diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts new file mode 100644 index 00000000..b31df324 --- /dev/null +++ b/src/app/lib/proxy.ts @@ -0,0 +1,171 @@ +// 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; + let currentWidgetId: string | null = null; + let timeoutId: ReturnType | undefined; + + 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 = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (currentWidgetId !== null) { + try { window.turnstile.remove(currentWidgetId); } catch { /* widget already gone */ } + } + 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; + + try { + const widgetId = window.turnstile.render(container, { + sitekey: siteKey, + action: "seal", + 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")); + }, + }); + currentWidgetId = widgetId; + window.turnstile.execute(widgetId); + } catch (err) { + if (settled) return; + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error("Turnstile render failed")); + } + }); + }); +} + +export async function proxyFetch( + path: string, + options?: RequestInit, +): Promise { + const defaultHeaders: Record = { + "Content-Type": "application/json", + }; + + const callerHeaders = + options?.headers instanceof Headers + ? Object.fromEntries(options.headers.entries()) + : (options?.headers as Record | undefined) ?? {}; + + const mergedHeaders = { + ...defaultHeaders, + ...callerHeaders, + // Always override — callers must not be able to spoof this header. + "X-Requested-With": "fetch", + }; + + return fetch(path, { + ...options, + headers: mergedHeaders, + }); +} + +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 ?? ""); + + const res = await proxyFetch("/api/proxy/seal", { + method: "POST", + headers: { + "cf-turnstile-response": turnstileToken, + }, + body: JSON.stringify({ token, purpose }), + }); + + if (!res.ok) { + let code = "unknown_error"; + try { + const body = (await res.json()) as { error?: string }; + code = body.error ?? code; + } catch { + // ignore parse errors — keep default code + } + throw new SealError(res.status, code); + } + + const data = (await res.json()) as { sealed: string }; + return data.sealed; +} diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 0aa87aef..c88c12a0 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(/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. */ @@ -17,10 +19,13 @@ 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"; - export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { // Strip OAuth params from captured URLs if (event.request?.url) { @@ -32,12 +37,13 @@ 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 + // Scrub URLs in stack trace frames and exception messages if (event.exception?.values) { for (const ex of event.exception.values) { if (ex.stacktrace?.frames) { @@ -47,6 +53,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; @@ -81,22 +91,22 @@ 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, // ── 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 ─────────────────── - allowUrls: [/^https:\/\/gh\.gordoncode\.dev/], + allowUrls: [new RegExp(`^${window.location.origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[/\\?#])`)], // ── Scrub sensitive data before it leaves the browser ──────── beforeSend: beforeSendHandler, 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/pages/PrivacyPage.tsx b/src/app/pages/PrivacyPage.tsx index 410fffc8..8ae75ccd 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, 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 + 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
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/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..5d44f32d 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"; @@ -330,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 @@ -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..174b1090 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"; @@ -80,9 +81,10 @@ export function clearAuth(): void { localStorage.removeItem(DASHBOARD_STORAGE_KEY); _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 + // 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" } }); }); // 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/src/types/turnstile.d.ts b/src/types/turnstile.d.ts new file mode 100644 index 00000000..e7415dff --- /dev/null +++ b/src/types/turnstile.d.ts @@ -0,0 +1,26 @@ +// Cloudflare Turnstile client-side API type declarations. +// Turnstile assigns `window.turnstile` synchronously when its script executes. + +interface TurnstileRenderOptions { + sitekey: string; + action?: string; + size?: "normal" | "compact" | "invisible" | "flexible"; + execution?: "render" | "execute"; + retry?: "auto" | "never"; + callback?: (token: string) => void; + "error-callback"?: (errorCode: string) => void; + "expired-callback"?: () => void; + "timeout-callback"?: () => void; +} + +interface Turnstile { + ready(callback: () => void): void; + 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/src/worker/crypto.ts b/src/worker/crypto.ts new file mode 100644 index 00000000..ddbd72e9 --- /dev/null +++ b/src/worker/crypto.ts @@ -0,0 +1,207 @@ +export interface CryptoEnv { + SEAL_KEY: string; // base64-encoded HKDF input key material (32 bytes recommended) + SEAL_KEY_NEXT?: string; // next HKDF key material for rotation (set before promoting to SEAL_KEY) +} + +// ── Base64url utilities ──────────────────────────────────────────────────── + +export function toBase64Url(bytes: Uint8Array): string { + const binary = String.fromCharCode(...bytes); + 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. + * 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, 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, + nextKey: 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 (nextKey !== undefined) { + const next = await deriveKey(nextKey, salt, info, "encrypt"); + return unsealToken(sealed, next); + } + + 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 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, + 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; + } +} + +export { SEAL_SALT }; diff --git a/src/worker/index.ts b/src/worker/index.ts index 105fff0a..639eb43a 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,18 +1,44 @@ -export interface Env { +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"; + +// Local interface — project does not install @cloudflare/workers-types. +// Matches the real Cloudflare ExecutionContext (waitUntil + passThroughOnException). +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} + +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) 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" + | "internal_error"; // Structured logging — Cloudflare auto-indexes JSON fields for querying. // NEVER log secrets: codes, tokens, client_secret, cookie values. @@ -41,7 +67,7 @@ function log( function errorResponse( code: ErrorCode, status: number, - corsHeaders: Record + corsHeaders: Record = {} ): Response { return new Response(JSON.stringify({ error: code }), { status, @@ -60,52 +86,210 @@ 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++; + // 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; + }, + }; } -// 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 + +// 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"); } -// CORS: strict equality only (SDR-004) -function getCorsHeaders( +// 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 { + 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; +} + +// Must check requestOrigin === allowedOrigin before reflecting. +// Returns empty object if no match — never reflects untrusted origins. +function buildCorsHeaders( requestOrigin: string | null, - allowedOrigin: string + allowedOrigin: string, + methods: string, + allowHeaders: string ): Record { - if (requestOrigin === allowedOrigin) { - return { - "Access-Control-Allow-Origin": allowedOrigin, - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Content-Type", - "Vary": "Origin", - }; + if (requestOrigin !== allowedOrigin) return {}; + return { + "Access-Control-Allow-Origin": allowedOrigin, + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": allowHeaders, + "Vary": "Origin", + }; +} + +// ── Proxy route patterns ───────────────────────────────────────────────────── +function isProxyPath(pathname: string): boolean { + return ( + pathname.startsWith("/api/proxy/") || + 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 { + 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 = 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 }); + } + return new Response(null, { + status: 204, + headers: { ...corsHeaders, "Access-Control-Max-Age": "86400", ...SECURITY_HEADERS }, + }); } - return {}; + + const result = validateProxyRequest(request, env.ALLOWED_ORIGIN); + if (!result.ok) { + log("warn", "proxy_validation_failed", { code: result.code, pathname }, request); + 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); + } + + return null; +} + +// ── Sealed-token endpoint ──────────────────────────────────────────────────── +const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token"]); + +// 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 = ""; + +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 + 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, "seal"); + 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); + } + // Purpose field required for token audience binding + if (typeof purpose !== "string" || purpose.length === 0) { + return errorResponse("invalid_request", 400); + } + if (!VALID_PURPOSES.has(purpose)) { + return errorResponse("invalid_request", 400); + } + + let sealed: string; + try { + // 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(); + _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(purpose, key); + } + sealed = await sealToken(token, key); + } catch (err) { + // Log error server-side — do not expose crypto error details in response + log("error", "seal_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-seal" } }); + return errorResponse("seal_failed", 500); + } + + log("info", "token_sealed", { + sessionId, + purpose, + token_length: token.length, + }, request); + + return new Response(JSON.stringify({ sealed }), { + status: 200, + headers: { + "Content-Type": "application/json", + ...SECURITY_HEADERS, + }, + }); } // ── Sentry tunnel ───────────────────────────────────────────────────────── @@ -152,6 +336,28 @@ async function handleSentryTunnel( return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } + 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 } }); + } + + 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); @@ -220,6 +426,7 @@ async function handleSentryTunnel( method: "POST", headers: sentryHeaders, body, + redirect: "error", }); log("info", "sentry_tunnel_forwarded", { @@ -234,6 +441,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 }); } } @@ -242,22 +450,39 @@ 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; +// 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 - 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; } @@ -267,6 +492,30 @@ async function handleCspReport(request: Request, env: Env): Promise { return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } + 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 } }); + } + + // 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 !== 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 }); @@ -329,6 +578,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) ) ); @@ -341,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, 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}$/; @@ -356,20 +606,51 @@ async function handleTokenExchange( return errorResponse("method_not_allowed", 405, cors); } - pruneTokenRateMap(); - const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; - if (!checkTokenRateLimit(ip)) { + 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" }), { status: 429, headers: { "Content-Type": "application/json", + "Retry-After": "60", ...cors, ...SECURITY_HEADERS, }, }); } + // Durable rate limiting — enforces global cross-isolate limit via CF binding. + // Keyed by "token:{ip}" to avoid collision with session-keyed proxy limits. + // 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); + } + } else { + log("error", "rate_limiter_binding_missing", {}, request); + return errorResponse("internal_error", 503, cors); + } + log("info", "token_exchange_started", {}, request); const contentType = request.headers.get("Content-Type") ?? ""; @@ -404,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, @@ -432,6 +713,7 @@ async function handleTokenExchange( client_secret: env.GITHUB_CLIENT_SECRET, code, }), + redirect: "error", } ); githubStatus = githubResp.status; @@ -441,10 +723,11 @@ 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); } - // 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" @@ -482,67 +765,145 @@ async function handleTokenExchange( }); } -export default { - async fetch(request: Request, env: Env): 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, +export default Sentry.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 = 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) + if (url.pathname.startsWith("/api/")) { + log("info", "api_request", { + method: request.method, + pathname: url.pathname, + cors_matched: corsMatched, }, request); + + if (!corsMatched && origin !== null) { + log("warn", "cors_origin_mismatch", { + request_origin: origin, + allowed_origin: env.ALLOWED_ORIGIN, + }, request); + } } - } - // CORS preflight for the token exchange endpoint only - if (request.method === "OPTIONS" && url.pathname === "/api/oauth/token") { - log("info", "cors_preflight", { cors_matched: corsMatched }, request); - return new Response(null, { - status: 204, - headers: { ...cors, "Access-Control-Max-Age": "86400", ...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 }, + }); + } - // Sentry tunnel — same-origin proxy, no CORS needed (browser sends as first-party) - if (url.pathname === "/api/error-reporting") { - return handleSentryTunnel(request, env); - } + // 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); - } + // 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/oauth/token") { + return handleTokenExchange(request, env, cors); + } - if (url.pathname === "/api/health" && request.method === "GET") { - return new Response("OK", { - headers: SECURITY_HEADERS, - }); - } + if (url.pathname === "/api/health" && request.method === "GET") { + return new Response("OK", { + headers: SECURITY_HEADERS, + }); + } - if (url.pathname.startsWith("/api/")) { - log("warn", "api_not_found", { - method: request.method, - pathname: url.pathname, - }, request); - return errorResponse("not_found", 404, cors); - } + // ── 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 }, + }); + } - // Forward non-API requests to static assets - return env.ASSETS.fetch(request); - }, -}; + // Step 3: Session middleware — ensureSession never throws + 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 }); + } + + // 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 + } + + 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 new file mode 100644 index 00000000..69a5eaae --- /dev/null +++ b/src/worker/sentry.ts @@ -0,0 +1,101 @@ +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"]+/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]"); +} + +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 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"], + // 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/src/worker/session.ts b/src/worker/session.ts new file mode 100644 index 00000000..502739c3 --- /dev/null +++ b/src/worker/session.ts @@ -0,0 +1,166 @@ +// Session cookie infrastructure for proxy request binding. +// +// 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 * as Sentry from "@sentry/cloudflare"; +import { + deriveKey, + signSession, + verifySession, + toBase64Url, + fromBase64Url, +} from "./crypto"; + +export interface SessionEnv { + SESSION_KEY: string; + SESSION_KEY_NEXT?: 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 + +// 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, + slot: "current" | "next" +): Promise { + 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(slot); + if (cached !== undefined) return cached; + const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign"); + _sessionKeyCache.set(slot, key); + return key; +} + +/** + * 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); + // 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))) + 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}`; + + 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 json = new TextDecoder().decode(fromBase64Url(encodedPayload)); + const payload = JSON.parse(json) as SessionPayload; + + // 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_NEXT !== undefined) { + const nextKey = await getSessionHmacKey(env, "next"); + valid = await verifySession(json, signature, nextKey); + } + if (!valid) return null; + + // Check expiry + if (payload.exp <= Math.floor(Date.now() / 1000)) return null; + + return payload; + } catch { + return null; + } +} + +/** + * 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 }; + } + + try { + const { cookie, sessionId } = await issueSession(env); + return { sessionId, setCookie: cookie }; + } catch (error) { + console.error(JSON.stringify({ + worker: "github-tracker", + 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/src/worker/turnstile.ts b/src/worker/turnstile.ts new file mode 100644 index 00000000..298e7fe4 --- /dev/null +++ b/src/worker/turnstile.ts @@ -0,0 +1,76 @@ +export interface TurnstileEnv { + TURNSTILE_SECRET_KEY: string; +} + +interface TurnstileResponse { + success: boolean; + action?: string; + "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, + expectedAction?: string +): 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; + 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 (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; + try { + data = (await resp.json()) as TurnstileResponse; + } catch { + return { success: false, errorCodes: ["network-error"] }; + } + + if (data.success) { + if (expectedAction !== undefined && data.action !== expectedAction) { + return { success: false, errorCodes: ["action-mismatch"] }; + } + 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/src/worker/validation.ts b/src/worker/validation.ts new file mode 100644 index 00000000..dd54ed3c --- /dev/null +++ b/src/worker/validation.ts @@ -0,0 +1,89 @@ +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). + * + * 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"); + 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 +): 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 contentTypeResult = validateContentType(request, "application/json"); + if (!contentTypeResult.ok) return contentTypeResult; + } + + return { ok: true }; +} 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/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts new file mode 100644 index 00000000..0c4e0a1f --- /dev/null +++ b/tests/app/lib/proxy.test.ts @@ -0,0 +1,645 @@ +// 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 { + ready: ReturnType; + 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; + /** 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()), + render: vi.fn((_container: HTMLElement, options: { + 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(), + remove: vi.fn(), + reset: vi.fn(), + _resolveToken(token: string) { + _successCb?.(token); + }, + _rejectWithError(code: string) { + _errorCb?.(code); + }, + _triggerExpired() { + _expiredCb?.(); + }, + _triggerTimeout() { + _timeoutCb?.(); + }, + }; + + 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("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("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); + + 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"); + + expect(mockTurnstile.ready).toHaveBeenCalledOnce(); + expect(mockTurnstile.render).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ action: "seal", retry: "never" }), + ); + }); + + 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"); + }); + + 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 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 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; + 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", + ); + }); +}); + +// ── 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", () => { + 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", "jira-api-token"); + expect(result).toBe("enc:abc123"); + }); + + it("throws SealError on 403 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "turnstile_failed" }), { status: 403 }), + ); + 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: 403, message: "turnstile_failed" }); + }); + + it("throws SealError on 429 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "rate_limited" }), { status: 429 }), + ); + 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: 429, message: "rate_limited" }); + }); + + it("throws SealError on 500 response", async () => { + setupMockedTurnstile("turnstile-tok"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "seal_failed" }), { status: 500 }), + ); + 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: 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", "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"); + + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ sealed: "enc:xyz" }), { status: 200 }), + ); + vi.stubGlobal("fetch", mockFetch); + + await mod.sealApiToken("raw-token", "jira-api-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", "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 and purpose 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", "jira-api-token"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + 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 () => { + vi.unstubAllEnvs(); + vi.stubEnv("VITE_TURNSTILE_SITE_KEY", ""); + + const freshMod = await loadModule(); + await expect(freshMod.sealApiToken("raw-token", "jira-api-token")).rejects.toThrow( + "VITE_TURNSTILE_SITE_KEY not configured", + ); + }); +}); diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 23f94f99..6491274e 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( @@ -48,6 +53,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=[REDACTED]", + ); + }); + + it("strips GitHub token prefixes (ghu_, ghp_, gho_, github_pat_)", () => { + expect(scrubUrl("Error: token ghu_abc123 exposed")).toBe( + "Error: token ghu_[REDACTED] exposed", + ); + 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]"); + }); }); describe("beforeSendHandler", () => { @@ -85,17 +105,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", () => { @@ -136,6 +158,57 @@ describe("beforeSendHandler", () => { const result = beforeSendHandler(event as never); expect(result).toBeDefined(); }); + + 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_[REDACTED]"); + }); + + it("scrubs client_secret and tokens from exception message values", () => { + 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=[REDACTED]"); + expect(result!.exception!.values![0].value).toContain("ghu_[REDACTED]"); + }); }); describe("beforeBreadcrumbHandler", () => { @@ -179,7 +252,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", @@ -212,3 +288,59 @@ 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("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(); + }); + + 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 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(); + 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/lib/worker-sentry.test.ts b/tests/lib/worker-sentry.test.ts new file mode 100644 index 00000000..3605a1df --- /dev/null +++ b/tests/lib/worker-sentry.test.ts @@ -0,0 +1,226 @@ +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 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" }, + 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_, github_pat_) from exception message", () => { + const event = { + request: { url: "https://example.com" }, + exception: { + values: [ + { + 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] or github_pat_[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" }; + 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 }, + }); + }); + + 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); + // 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("uses integration filter function to remove Console and replace RequestData", () => { + const opts = getWorkerSentryOptions({}); + 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); + }); +}); 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/validate-deploy-sync.test.ts b/tests/validate-deploy-sync.test.ts new file mode 100644 index 00000000..da9af9e7 --- /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): { + secrets: ScriptChecks; + viteVars: ScriptChecks; +} { + const secrets: ScriptChecks = { required: new Set(), warned: new Set() }; + const viteVars: ScriptChecks = { required: new Set(), warned: new Set() }; + + // 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+/)) { + secrets.required.add(name); + } + } + + // 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]); + } + + // 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]); + } + + // 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 { secrets, viteVars }; +} + +// ── 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", () => { + 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", () => { + const allChecked = new Set([...checks.secrets.required, ...checks.secrets.warned]); + const missing = optionalFields.filter((f) => !allChecked.has(f)); + 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.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", () => { + 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 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", () => { + const uncovered = parents.filter((p) => !envInterfaceFiles[p]); + expect(uncovered, `Add these to envInterfaceFiles in this test`).toEqual([]); + }); +}); diff --git a/tests/worker/crypto.test.ts b/tests/worker/crypto.test.ts new file mode 100644 index 00000000..6d6e1115 --- /dev/null +++ b/tests/worker/crypto.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi } from "vitest"; +import { test, fc } from "@fast-check/vitest"; +import { + toBase64Url, + fromBase64Url, + deriveKey, + sealToken, + unsealToken, + unsealTokenWithRotation, + signSession, + verifySession, +} from "../../src/worker/crypto"; + +// 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", () => { + 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 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 () => { + 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("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:other-purpose", "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"); + 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); + }); + + 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); + }); + + 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) ───────────────────────────────────────────── +// 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(); + } + ); +}); diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts index c3cb07fc..4f4af9c7 100644 --- a/tests/worker/csp-report.test.ts +++ b/tests/worker/csp-report.test.ts @@ -1,25 +1,44 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; 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==", + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, ...overrides, }; } +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}`, + }; + // 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, - headers: { "Content-Type": contentType }, + headers, body: method !== "GET" ? body : undefined, }); } @@ -27,14 +46,21 @@ function makeCspRequest( describe("Worker CSP report endpoint", () => { let originalFetch: typeof globalThis.fetch; let mockFetch: 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(() => {}); - 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(() => { @@ -42,8 +68,22 @@ 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" }); + 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); }); @@ -86,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([ { @@ -306,4 +366,183 @@ describe("Worker CSP report endpoint", () => { expect(report["original-policy"]).toBe("font-src 'self'"); 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); + }); + + // ── 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" } }); + 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("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" } }); + const req = makeCspRequest(body, "application/csp-report", "POST", { origin: null }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(403); + }); + + 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 }); + 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, "Origin": ALLOWED_ORIGIN }, + body, + }); + const resp = await worker.fetch(req, env); + 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, "Origin": ALLOWED_ORIGIN }, + 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, "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, "Origin": ALLOWED_ORIGIN }, + 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", "Origin": ALLOWED_ORIGIN }, + 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}`, + "Origin": ALLOWED_ORIGIN, + "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 logs = collectLogs(consoleSpy); + const sizeLog = findLog(logs, "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); + }); + + // ── 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 new file mode 100644 index 00000000..4c703522 --- /dev/null +++ b/tests/worker/helpers.ts @@ -0,0 +1,30 @@ +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; + 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 e6975bd7..bc05a14c 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import worker, { type Env } from "../../src/worker/index"; - -const ALLOWED_ORIGIN = "https://gh.gordoncode.dev"; +import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers"; function makeEnv(overrides: Partial = {}): Env { return { @@ -10,6 +9,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, }; } @@ -24,7 +27,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; @@ -47,32 +50,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; @@ -99,7 +76,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", @@ -131,12 +108,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", @@ -178,6 +156,52 @@ 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(); + }); + + 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 () => { @@ -244,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"); }); @@ -358,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"); }); @@ -409,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, @@ -424,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, @@ -705,6 +729,7 @@ describe("Worker OAuth endpoint", () => { headers: { "Origin": ALLOWED_ORIGIN, "Content-Type": "application/json", + "CF-Connecting-IP": `10.1.0.${++_requestCounter}`, }, body: "not-valid-json{{{", }); @@ -845,228 +870,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 new file mode 100644 index 00000000..11f1b477 --- /dev/null +++ b/tests/worker/seal.test.ts @@ -0,0 +1,685 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; +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 +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") }, + 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-token" }, + origin = ALLOWED_ORIGIN, + addXRequestedWith = true, + addContentType = true, + turnstileToken = "valid-turnstile-token", + method = "POST", + } = options; + + 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"; + 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; + 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(); + }); + + // ── 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, action: "seal" }), { 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-token" }), + }); + 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 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()); + + expect(res.status).toBe(403); + const json = await res.json() as Record; + 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, action: "seal" }), { 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 () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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"); + }); + + it("request proceeds when rate limiter throws (fail-open)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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 () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + + const longToken = "a".repeat(2049); + const req = makeSealRequest({ body: { token: longToken, purpose: "jira-api-token" } }); + 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, action: "seal" }), { status: 200 }) + ); + + const maxToken = "a".repeat(2048); + const req = makeSealRequest({ body: { token: maxToken, 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"); + }); + + it("request with missing purpose returns 400 with invalid_request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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, action: "seal" }), { 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 invalid purpose (not in VALID_PURPOSES) returns 400", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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, action: "seal" }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { purpose: "jira-api-token" } }); + 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 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 () => { + 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(); + }); + + // ── 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"); + // 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 () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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", + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, + }, + 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 () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { 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, action: "seal" }), { 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 + 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, action: "seal" }), { 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"); + }); + + // ── Seal operation logging ─────────────────────────────────────────────── + + it("successful seal logs token_sealed event with purpose and token_length", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + + const req = makeSealRequest({ body: { token: "ghp_abc123", purpose: "jira-api-token" } }); + await worker.fetch(req, makeEnv()); + + const allLogs = collectLogs(consoleSpy); + const sealLog = findLog(allLogs, "token_sealed"); + expect(sealLog).toBeDefined(); + 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.entry)).join("\n"); + 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 () => { + 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, 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"] }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(503); + }); + + // ── 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, action: "seal" }), { 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, action: "seal" }), { 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, action: "seal" }), { 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..d992bfb1 --- /dev/null +++ b/tests/worker/sentry-tunnel.test.ts @@ -0,0 +1,484 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; +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}`; + +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(); + }); + + // ── 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 () => { + 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("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 }); + 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(); + }); + + // ── 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/session.test.ts b/tests/worker/session.test.ts new file mode 100644 index 00000000..ee06b1e3 --- /dev/null +++ b/tests/worker/session.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi } from "vitest"; +import { + issueSession, + parseSession, + 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 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(); + }); + + 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("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("="); + + // 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}`, + 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 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, KEY_A not 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("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(); + }); + + 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/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/tests/worker/turnstile.test.ts b/tests/worker/turnstile.test.ts new file mode 100644 index 00000000..bda3d806 --- /dev/null +++ b/tests/worker/turnstile.test.ts @@ -0,0 +1,248 @@ +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 (no action binding)", 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: 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( + 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 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", { + 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); + }); +}); diff --git a/tests/worker/validation.test.ts b/tests/worker/validation.test.ts new file mode 100644 index 00000000..f23ed4fa --- /dev/null +++ b/tests/worker/validation.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect } from "vitest"; +import { + validateOrigin, + validateFetchMetadata, + validateCustomHeader, + validateContentType, + validateProxyRequest, +} from "../../src/worker/validation"; +import { ALLOWED_ORIGIN } from "./helpers"; + +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("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 }); + }); +}); 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 e2a74cf2..94fa7f57 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,11 @@ name = "github-tracker" main = "src/worker/index.ts" compatibility_date = "2026-03-01" +compatibility_flags = [ + "global_fetch_strictly_public", + # nodejs_als: required by @sentry/cloudflare for AsyncLocalStorage (request context propagation) + "nodejs_als", +] workers_dev = false [assets] @@ -10,12 +15,22 @@ 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 = "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" +namespace_id = "1001" + +[ratelimits.simple] +limit = 10 +period = 10 [observability] enabled = true