Skip to content
askdareDocumentation

    ADR-0004: Threat model for a public, anonymous chatbot

    Context

    v1 was effectively an open, paid proxy:

    • No authentication or rate limits.
    • The client could override assistantId.
    • Uploads were unbounded.
    • GET /files/:id exposed metadata for any file in the organization.
    • CORS reflected any origin, and postMessage used '*'.

    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 from CHAT_SECRET via HKDF) with typ, aud = botId, iss, exp, iat, sub and kid:
        • 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 kid with no fallback: an unknown or missing kid is rejected.
      • Claim formats are validated (v_<32 hex> visitor IDs, conv_… conversation IDs).
      • CHAT_SECRET must be at least 32 random characters, otherwise startup fails. Rotation via CHAT_SECRET_PREVIOUS.
      • visitorId is generated by the server.
    • The client never chooses the model, instructions, tools, vector stores, raw file_id values 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 with after() 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-upstash shares 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 plus trustProxy. The leftmost X-Forwarded-For value is spoofable.
    • Transport: no cookies, Bearer tokens only (no CSRF), JSON-only POST bodies.
    • Origin: a per-bot frame-ancestors allowlist 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 (or user:<id> when signed in), keyed from CHAT_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 HumanVerifier port 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 separate IDENTITY_SECRET JWT issued by the host's backend (ADR-0009).

    Required before any public deployment, including demos

    1. Session tokens, server-side ownership checks and signed attachment handles.
    2. Limits and daily budgets, a kill switch, and token, step, turn and input caps.
    3. A dedicated OpenAI project with a restricted key, a model allowlist, a hard spend limit and tested alerts.
    4. A frame-ancestors allowlist with no *. No wildcard CORS. The client cannot select the model, instructions, tools or stores.
    5. Sanitized markdown, plus upload type and size validation.
    6. No content or PII in logs. A written retention decision, a privacy notice and an AI disclosure.
    7. A random CHAT_SECRET of at least 32 bytes with documented rotation. Errors that don't leak internals.