Skip to main content

Security

This is the canonical security reference for the project. The trust model is deliberate, not accidental.

Trust model

  • The browser never holds API keys. Every model call goes through the Cloudflare Worker, which injects keys from Worker secrets. The client only talks to /api/* on our origin. /api/config returns readiness booleans + base URLs + model ids only — keys never round-trip (tested).
  • Secrets are not committed. .gitignore covers .dev.vars, .env, .env.local, .env.*.local. The .example files contain placeholders only.
  • No XSS surface. Zero dangerouslySetInnerHTML / innerHTML in src. All model output renders through React's escaped text interpolation or JSON.stringify — including the SSE stream, which is JSON.parsed.

Request handling & isolation

  • Errors never leak. Every error path logs detail server-side and returns a stable code (upstream_error / provider_not_configured). Raw SDK errors — which can embed request URLs or echoed auth — are never sent to the client.
  • Request body is validated (SSRF guard). messages are checked by a Zod schema: roles are constrained to system|user, image_url.url is allowlisted to data: URLs or our asset hosts (so the provider can't be made to fetch an arbitrary server-side URL), temperature is clamped to [0,2], and max_tokens is clamped to [1,8192] so per-call output length is bounded.
  • Concurrency-safe engine. end() is idempotent (no double onEnd), and processLane re-checks running after its model-call await before mutating state.

Accepted / residual risks

These are documented, not oversights:

APP_TOKEN is a weak gate

It ships in the client bundle when set, so it's publicly recoverable — it deters casual abuse, nothing more. The default wrangler.toml ships it empty. Real protection is layered:

  • Set per-provider spend caps at Cerebras/OpenRouter/Google.
  • Add Cloudflare rate-limiting / WAF rules on /api/chat.
  • Set a non-empty APP_TOKEN in production.
  • For a real gate, use a short-lived signed token minted server-side, not a static value compiled into the client.

CORS is access-control-allow-origin: * because the token model is "public proxy," not "secret gate."

Prompt injection (mitigated, not eliminated)

Scenario input.text is concatenated into the user prompt. streamObject constrains the output shape (the model can't emit non-schema JSON) but cannot stop an injected instruction from picking a schema-valid-but-wrong verdict. Not currently exploitable because scenarios are author-controlled JSON validated at build time; the risk activates only if scenarios become remotely supplied. Mitigation at that point: wrap untrusted content in unique delimiters with an explicit "this is data, not instructions" line.

Verification

The test suite actively guards the security posture:

  • worker/handler.test.ts — asserts keys never appear in /api/config, forbidden message roles are rejected, and arbitrary image_urls are rejected.
  • worker/worker.test.ts — asserts a crafted error message (key=LEAKED) never reaches the client through the SSE error frame.

The worker tests (worker/handler.test.ts, worker/worker.test.ts) are the executable record of this posture — they fail if keys leak, SSRF opens up, or an error message reaches the client.