Skip to content

feat: per-key CORS origin allowlist for browser embedding#36

Open
wms2537 wants to merge 1 commit into
feat/rate-limitsfrom
feat/cors-origin-whitelist
Open

feat: per-key CORS origin allowlist for browser embedding#36
wms2537 wants to merge 1 commit into
feat/rate-limitsfrom
feat/cors-origin-whitelist

Conversation

@wms2537
Copy link
Copy Markdown
Collaborator

@wms2537 wms2537 commented May 8, 2026

Summary

Lets a key be safely embedded in a public widget. Stacks on top of #34 (rate-limits) — base is feat/rate-limits so the diff stays clean.

Why

A partner is building a chat widget on a GitHub Pages docs site that hits /api/v1/chat/completions. Their static page can't keep an API key secret. Without origin restrictions, anyone scraping the page could lift the key. With this change, the key is bound to specific origins server-side: the browser still has to send the key, but the worker rejects it from any origin not on the allowlist (and the missing ACAO on a 403 means hostile pages can't even read the response).

API model

allowed_origins value Meaning
null (default) unrestricted — server-to-server use, existing behavior
[] locked, no browser may use this key
["https://docs.example.com"] only this origin may use it from a browser
["*"] any browser origin

Server-to-server callers (no Origin header) ignore the column entirely, so existing CLI / cron / SDK use is unaffected.

What's new

  • Migration 0006_api_key_origins.sqlALTER TABLE api_keys ADD COLUMN allowed_origins TEXT
  • Library apps/web/src/lib/cors.tsevaluateCors, preflightResponse, validateOriginList, parseAllowedOrigins
  • Auth validateRequest + MCP validateApiKey carry allowedOrigins through
  • Routes chat, /api/v1/usage, every /api/v1/tools/*, and /mcp:
    • 403 origin_not_allowed if Origin is set and not on the allowlist
    • Reflect Origin in Access-Control-Allow-Origin, set Vary: Origin, expose all rate-limit headers via Access-Control-Expose-Headers
    • Standardised OPTIONS via preflightResponse() reflecting the requested headers
  • PATCH /api/keys/[id] accepts { name?, allowedOrigins? } (owner-scoped)
  • UI /dashboard/keys adds an "Allowed origins" editor per key with chip-style display, edit-in-place textarea, "unrestricted" shortcut, validation errors surfaced inline
  • listApiKeys returns allowed_origins so the UI renders current state

Live verification

  • Migration applied to remote D1 arbbuilder
  • Worker deployed (version b25cf685-0941-4f96-a160-22cd805ec8f6)
  • Verified end-to-end against the partner's key (ethcluj) which is now bound to https://stylus-developers-guild.github.io:
preflight from allowed origin   → 204, ACAO matches, Vary: Origin
POST from allowed origin        → 200, ACAO matches, expose-headers present
POST from disallowed origin     → 403 (no ACAO — browser blocks it)

Test plan

  • cd apps/web && npm test → 20/20 pass
  • npx tsc --noEmit clean
  • Edit an existing key on /dashboard/keys, add https://example.com, save, fetch with that Origin → 200 with reflected ACAO
  • Same key, fetch with another Origin → 403 origin_not_allowed
  • Server-side curl with no Origin header → 200 (allowlist ignored)
  • OPTIONS preflight from any origin → 204 reflecting requested headers
  • Switch back to "unrestricted" → calls from any origin succeed again

🤖 Generated with Claude Code

Adds an `allowed_origins` JSON column to api_keys so a key can be safely
embedded in a public widget — anyone scraping the key from the page can
still only use it from the listed origins.

Semantics:
  null   -> unrestricted (server-to-server, default; existing behavior)
  []     -> locked, no browser may use this key
  [...]  -> only these origins may use this key from a browser
  ["*"]  -> any browser origin

Server-to-server callers (no Origin header) bypass the allowlist entirely,
so server keys keep working without configuration.

API surface:
- New lib `cors.ts`: evaluateCors(), preflightResponse(), validateOriginList()
- validateRequest + MCP validateApiKey return allowedOrigins
- Chat, /api/v1/usage, every /api/v1/tools/*, and /mcp now:
    * 403 with `origin_not_allowed` if Origin header is set and not in list
    * Echo Origin in ACAO + Vary: Origin on every response
    * Standardised OPTIONS via preflightResponse() reflecting requested headers
    * Expose all rate-limit headers via Access-Control-Expose-Headers
- PATCH /api/keys/[id] supports updating { name, allowedOrigins }
- /dashboard/keys: new OriginsEditor with per-key add/remove/lock/unrestrict
- listApiKeys returns allowed_origins so the UI can render current state

Migration 0006_api_key_origins.sql applied to remote.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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