Skip to content

Security: kapoordeepanshu/DocsChatAI

Security

SECURITY.md

Security Policy

Reporting a Vulnerability

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.

Supported Versions

Pre-1.0. Only the latest commit on main receives security fixes.


Threat Model

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.


Controls

There is no identity system, deliberately

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.

Optional request signing

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 strongest control was built for another reason

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.

Rate limiting and load shedding

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.

What is stored

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.

SSRF protection in the crawler

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/https schemes — 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.

XSS

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.

CORS and origin validation

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.

Logging and privacy

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.


Operator Responsibilities

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=true unless 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.

There aren't any published security advisories