ADR-0004: Threat model for a public, anonymous chatbot
- Status: Accepted. Detailed in THREAT_MODEL.md and the going-public guide.
- Date: 2026-10-29
Context
v1 was effectively an open, paid proxy:
- No authentication or rate limits.
- The client could override
assistantId. - Uploads were unbounded.
GET /files/:idexposed metadata for any file in the organization.- CORS reflected any origin, and
postMessageused'*'.
An anonymous public endpoint cannot be made unforgeable. A determined attacker can always use it as a rate-limited LLM proxy.
Decision
Goal: bound the damage (dollars per day, requests per IP or visitor) and prevent escalation (IDOR, tool misuse, XSS, exfiltration). The controls map to OWASP Top 10 for LLM Applications 2025 entries LLM01, LLM02, LLM05, LLM06, LLM07 and LLM10.
- Tokens:
- Two kinds, both encrypted JWE (
dir+ A256GCM, keys derived fromCHAT_SECRETvia HKDF) withtyp,aud = botId,iss,exp,iat,subandkid:- access token: 15 minutes, kept in memory;
- resume token: 30 days; it carries the conversation ID, which stays opaque to the client.
- The key is looked up by
kidwith no fallback: an unknown or missingkidis rejected. - Claim formats are validated (
v_<32 hex>visitor IDs,conv_…conversation IDs). CHAT_SECRETmust be at least 32 random characters, otherwise startup fails. Rotation viaCHAT_SECRET_PREVIOUS.visitorIdis generated by the server.
- Two kinds, both encrypted JWE (
- The client never chooses the model, instructions, tools, vector stores, raw
file_idvalues or OpenAI conversation IDs. It only gets signed handles. - Limits:
- new sessions and new conversations per IP; renewals by returning visitors counted per visitor;
- messages per visitor and per IP per minute; turns per conversation;
- daily token budgets per bot, per IP and (optionally) global. Each turn atomically reserves an estimate (history + message +
max_output_tokens) before calling the model; real usage replaces it afterwards, and failed turns or turns cut short by the deadline keep the estimate charged. A turn whose model call has started runs to completion even when the visitor disconnects (kept alive withafter()on serverless hosts), because OpenAI keeps no record of an aborted streamed response, usage included (verified live on 2026-10-29), so stopping it would hide its cost; - max input characters,
max_output_tokens, max tool steps; - kill switch via an env var.
- Counters live in memory by default (budgets are never evicted; the store fails closed when full);
@boldare/askdare-upstashshares them across instances. - Client IPs are validated and IPv6 is grouped by /64.
- Required deployment guidance: a dedicated OpenAI project with a restricted key, a model allowlist and a hard spend limit. This is the backstop that needs no infrastructure.
- Client IP:
getClientIp(req)with Vercel and Heroku presets plustrustProxy. The leftmostX-Forwarded-Forvalue is spoofable. - Transport: no cookies, Bearer tokens only (no CSRF), JSON-only POST bodies.
- Origin: a per-bot
frame-ancestorsallowlist with no*by default. Origin is a browser signal, not authentication. - Output and uploads:
- Markdown is sanitized with no raw HTML and with link and image allowlists.
- Uploads are validated by magic bytes, SVG is rejected, and the default cap is 4 MB.
- Abuse attribution:
safety_identifier= an HMAC of the visitor ID (oruser:<id>when signed in), keyed fromCHAT_SECRET, so a guessable ID can't be reversed (2026-10-30; it was a plain SHA-256 before the first release). - Budgets that run out for a whole bot or globally are logged (and passed to
onError) once per day, without IPs, so the operator learns the chat stopped answering. - Logs contain no message content or PII by default. Errors map to stable codes with no internals or stack traces.
- Optional:
- a
HumanVerifierport with a Cloudflare Turnstile adapter, checked at session creation only. Not built in 0.1 (deferred on 2026-10-29): it needs Cloudflare's script inside the iframe, which loosens its CSP, and limits and budgets come first. We revisit it if they prove insufficient; identify()with a separateIDENTITY_SECRETJWT issued by the host's backend (ADR-0009).
- a
Required before any public deployment, including demos
- Session tokens, server-side ownership checks and signed attachment handles.
- Limits and daily budgets, a kill switch, and token, step, turn and input caps.
- A dedicated OpenAI project with a restricted key, a model allowlist, a hard spend limit and tested alerts.
- A
frame-ancestorsallowlist with no*. No wildcard CORS. The client cannot select the model, instructions, tools or stores. - Sanitized markdown, plus upload type and size validation.
- No content or PII in logs. A written retention decision, a privacy notice and an AI disclosure.
- A random
CHAT_SECRETof at least 32 bytes with documented rotation. Errors that don't leak internals.