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