Skip to content

feat(worker): proxy security hardening — 7-layer defense-in-depth stack#62

Merged
wgordon17 merged 56 commits into
gordon-code:mainfrom
wgordon17:worktree-proxy-security-hardening
Apr 15, 2026
Merged

feat(worker): proxy security hardening — 7-layer defense-in-depth stack#62
wgordon17 merged 56 commits into
gordon-code:mainfrom
wgordon17:worktree-proxy-security-hardening

Conversation

@wgordon17
Copy link
Copy Markdown
Member

Summary

  • Implements 7-layer defense-in-depth for CF Worker proxy endpoints: WAF rules (documented), request validation, session cookies, Workers Rate Limiting Binding, Turnstile challenges, AES-256-GCM sealed tokens with purpose binding, and SSRF hardening
  • Adds 6 new Worker modules (crypto, validation, session, turnstile, proxy, type declarations) with 132 new tests across 6 test files, plus integration tests for the /api/proxy/seal endpoint
  • Updates CSP to allowlist Turnstile domains, DEPLOY.md with WAF rules/secrets/local dev docs, and wrangler.toml with rate limiting binding and global_fetch_strictly_public flag

wgordon17 added 30 commits April 8, 2026 21:23
- sealApiToken: add purpose parameter, include in POST body (CRIT-001, 6/7 reviewers)
- ensureSession: wrap issueSession in try/catch, fallback to random sessionId on error (SEC-002, STRUCT-005)
- handleProxySeal: add VALID_PURPOSES allowlist + 64-char max-length for purpose field (SEC-003, QA-002)
- validateAndGuardProxyRoute: include CORS headers on validation error responses (SEC-004)
- session.ts: cache derived HMAC keys at module level to avoid repeated HKDF derivation (PERF-001/002)
- turnstile.ts: add 5s AbortController timeout to siteverify fetch (PERF-003)
- proxy.test.ts: update sealApiToken calls with purpose, assert body.purpose field, add error field test
- seal.test.ts: update purpose values to match VALID_PURPOSES allowlist
- crypto.test.ts: add cross-purpose isolation test (F-003)
- adds Turnstile token length guard (>2048) with boundary tests
- adds seal key derivation cache (_sealKeyCache Map, bounded by VALID_PURPOSES)
- passes pre-parsed pathname to validateAndGuardProxyRoute (eliminates redundant URL parse)
- unifies session key cache into single Map (removes duplicate getSessionHmacPrevKey)
- fixes structured error logging in ensureSession catch path
- removes dead options parameter from validateProxyRequest
- corrects HKDF key material descriptions in CryptoEnv and DEPLOY.md
- fixes test key comments to match actual decoded values
- adds 14 new tests covering previously-untested paths
- Sets retry: 'never' on widget options to prevent setTimeout leak
  when turnstile.remove() is called during a retry cycle
- Wraps render+execute in turnstile.ready() per Cloudflare docs to
  guard against race conditions with script loading
- Hoists widgetId to outer scope so timeout cleanup works across the
  ready() callback boundary
- Adds ready() and retry to Turnstile type declarations
- Adds ready mock to test fixture
- Adds settled guard at start of ready() callback to prevent
  widget creation after timeout fires
- Wraps render+execute in try/catch for immediate rejection on
  render failure instead of 30s hang
- Wires timeout-callback to reject immediately on Turnstile
  internal challenge timeout
- Makes X-Requested-With header non-overridable in proxyFetch
- Adds tests: timeout-callback, X-Requested-With non-override,
  retry:never assertion, ready() call assertion
- Clears 30s setTimeout in cleanup() to prevent timer leak on
  normal promise resolution
- Moves execute() inside try/catch alongside render() so both
  throw paths reject immediately
- Wraps turnstile.remove() in try/catch inside cleanup() to
  prevent remove() failures from blocking reject()
- Adds test for render() throwing synchronously
- Ensures timeoutId is assigned before ready() fires so cleanup()
  can clear the timer even when the callback resolves synchronously
- adds HKDF-SHA256 KAT (RFC 5869 A.1) and AES-256-GCM KAT (McGrew-Viega TC14)
- adds 6 property-based fuzz tests via @fast-check/vitest
- switches verifySession to SHA-256 pre-hash + timingSafeEqual (CF Workers)
- adds SubtleCrypto.timingSafeEqual type augmentation
- fixes test key construction to produce actual 32-byte keys
- Extracts createIpRateLimiter factory from token exchange rate limiter
- Adds IP rate limiting to Sentry tunnel (15/min), CSP report (15/min)
- Adds strict origin check to Sentry tunnel via validateOrigin
- Adds soft origin check to CSP report (allows absent Origin)
- Adds Content-Length pre-check before body reads on both tunnels
- Adds IP pre-gate (60/min) before ensureSession on proxy routes
- Removes dead GitLab route, purpose, and test references
- Documents tunnel security model in DEPLOY.md
- Migrates Sentry tests to dedicated sentry-tunnel.test.ts
- Extracts shared test helpers to tests/worker/helpers.ts
- Adds getClientIp helper with missing-header warning for misconfiguration
- Removes raw SEAL_KEY from cache key (purpose + fingerprint rotation)
- Adds CSP field sanitization: control chars stripped, 2048-char cap
- Adds durable rate limiting to token exchange via PROXY_RATE_LIMITER
- Adds consecutive failure tracking for durable rate limiter
- Standardizes consoleSpy naming across test files
- getClientIp returns null instead of falling back to 'unknown'
- All endpoints reject 400 when CF-Connecting-IP is absent
- PROXY_RATE_LIMITER binding validated before use (503 if missing)
- Transient .limit() errors still fail open; missing binding fails closed
- Tests updated to include CF-Connecting-IP on all inline requests
- Tests: missing CF-Connecting-IP returns 400 (sentry, csp, oauth, seal)
- Tests: missing PROXY_RATE_LIMITER binding returns 503 (oauth, seal)
- DEPLOY.md: documents CF-Connecting-IP requirement, binding validation,
  CSP field sanitization, durable token exchange rate limiting,
  Content-Length byte ceilings
- Remove hardcoded SENTRY_DSN constant; read from import.meta.env.VITE_SENTRY_DSN at runtime
- Replace hardcoded allowUrls regex with window.location.origin (string prefix match)
- Add VITE_SENTRY_DSN and VITE_TURNSTILE_SITE_KEY to deploy.yml build env
- Document VITE_SENTRY_DSN in .env.example
- Add initSentry tests covering no-op (empty DSN), DSN forwarding, and allowUrls
- Removes 'https://gh.gordoncode.dev' from ALLOWED_ORIGINS_DEFAULT
- Adds startup warning when MCP_RELAY_ALLOWED_ORIGINS is not set
- Documents MCP_RELAY_ALLOWED_ORIGINS in mcp/README.md
- Accepts optional base_url argument in waf-smoke-test.sh
- Gates WAF smoke test in deploy.yml on DEPLOY_DOMAIN variable
- Adds DEPLOY comments to wrangler.toml for routes and SENTRY_DSN vars
- Moves SENTRY_DSN from wrangler.toml [vars] to wrangler secret (commented template)
- Creates scripts/validate-deploy.sh with CI and local modes
- CI mode checks required env vars (fails on VITE_GITHUB_CLIENT_ID, CF credentials; warns on optional)
- Local mode checks CF Worker secrets via wrangler secret list
- Adds validate:deploy script to package.json
- Adds validation step to deploy.yml before build (never echoes secret values)
- Adds Fork Deployment Checklist section before GitHub Actions section
- Covers required steps, optional config, and static-only deployment guide
- Updates OAuth App URLs to use YOUR-DOMAIN placeholder
- Updates ALLOWED_ORIGIN example to use YOUR-DOMAIN placeholder
- Updates WAF rule expression to use YOUR-DOMAIN placeholder
- Updates SENTRY_DSN docs to reflect Worker secret (not wrangler.toml [vars])
- fix SEAL_KEY fingerprint collision (full key comparison, not 8-char slice)
- redesign _sessionKeyCache with fingerprint+purpose pattern
- add Turnstile action binding validation (action:'seal')
- fix Sentry allowUrls: anchored RegExp with boundary
- fix DEPLOY.md: CSP origin check documented as strict
- add TURNSTILE_SECRET_KEY to validate-deploy.sh
- fix CSP rate-limit test to send valid requests with Origin
- add tests: 30s timeout, script reuse, jira-refresh-token,
  SEAL_KEY rotation, Turnstile action mismatch integration
- fix toBase64Url: spread instead of byte-by-byte concat
- fix stale comment, remove redundant X-Requested-With
- replace inline btoa/atob+replace chains with toBase64Url/fromBase64Url
- remove spurious blank line in sentry-tunnel.test.ts
@wgordon17 wgordon17 force-pushed the worktree-proxy-security-hardening branch from e7d6aad to 4953c38 Compare April 14, 2026 15:23
- Move security analysis (rate limits, WAF exemptions, threat model) from
  DEPLOY.md to hack/docs/security-runbook.md
- Add SENTRY_SECURITY_TOKEN to .dev.vars.example, DEPLOY.md, validate-deploy.sh
- Simplify verifySession: 3 crypto ops to 1 (crypto.subtle.verify)
- Consolidate getCorsHeaders/getProxyCorsHeaders into buildCorsHeaders
- Fix CSP scrub ordering: scrub URLs before 2048-char truncation
- Remove design-doc codes (SDR-/SC-/ADV-) from all source and test files
- Remove purpose.length > 64 dead code from seal endpoint
- Fix wrangler.toml rate limit: period=10 limit=10 (CF free-tier)
- Add tests: CSP token prefix scrubbing, SENTRY_SECURITY_TOKEN forwarding,
  non-string token validation, Set-Cookie on 405, crypto.subtle.verify spy
- Export shared ALLOWED_ORIGIN from test helpers
Adds https://gh.gordoncode.dev to ALLOWED_ORIGINS_DEFAULT so the
WebSocket relay works out of the box for deployed SPA users.
MCP_RELAY_ALLOWED_ORIGINS env var remains for custom domains/forks.
Adds a test that ensures validate-deploy.sh stays in sync with
the TypeScript Env interfaces. Catches missing, stale, and
unknown Worker secrets. Also warns about key rotation secrets
(SEAL_KEY_PREV, SESSION_KEY_PREV) that were previously unchecked.
Move static-only deployment section above OAuth setup to present both
paths as equal choices. Replace 'Required' heading with 'OAuth +
Cloudflare Worker' to clarify the backend is only needed for OAuth.
Tag Worker-specific optional items so static deployers can skip them.
validate-deploy.sh:
- Remove --ci flag; single path checks VITE_ vars + wrangler secrets
- Resolve wrangler via node_modules/.bin (not just global PATH)
- Use --format json (wrangler v3 dropped --json flag)
- Fix grep patterns for pretty-printed JSON (whitespace-tolerant)
- Check .env files as fallback for VITE_ vars (Vite loads at build)

Key rotation (_PREV → _NEXT):
- Rename SESSION_KEY_PREV/SEAL_KEY_PREV to _NEXT variants
- Sign/seal with NEXT key during rotation, verify/unseal with both
- Fixes operational flow: _PREV required knowing the current secret
  value, which is impossible to retrieve from Cloudflare (write-only)
- _NEXT only requires the value you just generated
Session keys rotate in 8 hours (cookie Max-Age). Seal keys protect
localStorage tokens with no expiry — promoting SEAL_KEY before all
clients re-seal makes old tokens permanently unreadable.
@wgordon17 wgordon17 merged commit 9dd9fcb into gordon-code:main Apr 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant