Do not open a public issue for security vulnerabilities.
Report privately via GitHub Security Advisories (preferred — it gives us a private channel with you and a CVE path), or email deepanshukapoor [at] live [dot] in.
Please include the version or commit, reproduction steps, and what an attacker gains. We aim to acknowledge within 3 business days and to ship a fix or a mitigation plan within 30 days for confirmed issues.
Pre-1.0. Only the latest commit on main receives security fixes.
DocsChatAI is self-hosted: whoever deploys it operates their own server. That shapes who defends what.
| Actor | Trust | Assumption |
|---|---|---|
| The deploying company | Trusted | Operates the server, controls the docs, owns authentication |
| Their middleware, if any | Trusted | Whatever it forwards has already been checked by them |
| A product installation | Untrusted | May be compromised or abusive; its key is readable from the browser |
| An end user in the widget | Untrusted | Sends arbitrary text |
| A docs URL given to the crawler | Untrusted | May redirect anywhere, including internal networks |
Two consequences shape everything below. Many untrusted installations share one server, so no installation may degrade another — hence per-key limits rather than global ones. And the server never learns who anyone is, so every control has to work without identity.
An earlier design gave each installation a key, stored hashed, with states and revocation — treated as authentication. That was wrong for this deployment shape. The key lives in a browser widget, so anyone can read it out of devtools in seconds. Against a hostile user it is not a credential, and a key lifecycle built around it would have been security theatre.
A key is now an identifier for rate limiting, nothing more. The real security boundary is one of:
| Boundary | When it applies |
|---|---|
| The network | The server isn't reachable from outside |
| The deployer's own middleware | Checks their session, licence or tenant rules — because they already know who their users are, and this project deliberately does not |
The widget forwards an opaque context object which the server never parses,
logs or stores. That is where a deployer plugs in their own authentication.
If middleware does the authenticating, this server must not be publicly reachable — otherwise the middleware is simply skipped. Bind it to a private interface or allowlist the middleware's address. The server logs a warning at startup when it detects a public bind with no signature required.
For a public endpoint, middleware can sign the request body (HMAC-SHA256, hex,
in X-DCA-Signature) and the server verifies it. Stateless: no database, no
per-installation records, nothing revealing who deployed anything. Compared with
hmac.compare_digest, so verification isn't timing-variable.
The server refuses to call the model when nothing retrieves above
APP_RETRIEVAL_MIN_SCORE. Someone who finds the endpoint and asks it to write
code is refused for about 5 ms of embedding — the GPU is never touched. The
endpoint can only ever answer questions about the deployed documentation, so it
cannot be harvested as a free general-purpose LLM.
This also blunts prompt injection: an instruction to ignore the system prompt still has to retrieve relevant documentation before it reaches the model at all.
On by default. Disable with APP_RATE_LIMIT_ENABLED=false only when something in
front already does it.
- Per key, or per IP when there's no key — never global, so one noisy installation can't exhaust everyone else's budget.
- Daily quota as a cost ceiling.
- Concurrency cap on generation. Rate limits bound requests over time; they do nothing about thirty arriving in the same second, which is what actually saturates a GPU.
- Queue depth, past which requests get 503. Hundreds of slow timeouts serve nobody; refusing quickly keeps accepted requests fast.
It fails closed. If Redis is unreachable the server returns 503 rather than serving unlimited traffic — running without the limiter invites exactly the abuse it exists to prevent.
Conversations live in Redis for 24 hours, refreshed on each turn, then they
are gone. Only the turns themselves. The opaque context is never persisted:
we don't interpret it, so retaining a deployer's tenant names and session tokens
would be a liability with no upside.
The crawler accepts a URL from an operator and fetches it, which makes it a
potential probe into internal networks. Mitigations in
src/docschatai/ingestion/crawler.py:
- Only
http/httpsschemes —file://,gopher://and friends are rejected. - Hostnames are resolved and refused if they map to private, loopback, link-local or reserved addresses.
- Re-validated on every hop, because a redirect or a discovered link can point somewhere the start URL did not.
- Crawling stays on the start URL's host.
The widget builds every DOM node programmatically and inserts model output with
textContent — it never assigns innerHTML for any server- or model-derived
string. This is stronger than sanitizing HTML after the fact: there is no HTML
parse step to bypass. Server-side responses are additionally sanitized with
bleach.
The widget also renders inside a Shadow DOM, isolating it from the host product's styles and scripts in both directions.
APP_ALLOWED_ORIGINS is an explicit list and there is no wildcard option — a
wildcard would let any page on the internet point a widget at your server and
spend your GPU time. An empty list rejects browser requests, which is the right
default for a server nobody has configured yet.
Treat this as defence in depth, not a boundary: Origin is only enforced by
browsers, and anything speaking HTTP directly can claim whatever it likes.
Question and answer content is not logged. The opaque context object is
never logged or stored either.
This is a deliberate default. End users type real problems — sometimes including internal hostnames, IPs, or configuration details — into a support box. Retaining that by default creates a breach liability nobody asked for.
Self-hosting means these are yours, not ours:
- Terminate TLS. Questions and answers are in the clear over plain HTTP.
- Decide where your security boundary is — the network or your middleware — and make sure the server isn't reachable around it.
- Set
APP_ALLOWED_ORIGINS. It's empty by default and browsers will be refused until you do. - Keep
APP_RATE_LIMIT_ENABLED=trueunless something in front of the server is already limiting. - Keep models and dependencies patched.
- GDPR/CCPA compliance is yours. You control the deployment, the docs, and any logs. If you enable full question/answer logging, you are processing user data and must have a lawful basis, a retention policy, and a way to honor deletion requests. This project is not a hosted service and has no access to your data.
- Only index docs you own or control. The crawler is for your own documentation. It is not a general-purpose scraper, and using it as one may violate the target site's terms.