Security Hardening
Authentication
Set API_KEY_CLIENTS to a JSON object mapping each API key to the client id that owns its data.
The mapping is the authentication source: missing or invalid keys, and an empty mapping, fail
closed with HTTP 401. API key comparison uses constant-time comparison across the configured
mapping.
Failed authentication attempts are rate-limited per source address with
AUTH_FAILURE_RATE_LIMIT=10 by default and logged without raw key values. Valid protected
requests use the Redis-backed sliding-window RATE_LIMIT=30 by default, keyed by the resolved
client. A limited request returns HTTP 429 with X-RateLimit-Limit, X-RateLimit-Remaining,
and X-RateLimit-Reset; RATE_LIMIT=0 disables the built-in limiter.
Client Isolation
The server derives client_id only from X-API-Key; callers cannot provide it as a query,
filter, or metadata value. The protected identity is (client_id, document_id), and every
Qdrant read/write is scoped to the resolved client. A probe for another client's document or
job returns 404, rather than revealing whether that resource exists.
Reserved Keys and Paths
User metadata cannot claim server-owned roots such as metadata, entities, client_id,
version_id, deleted, deleted_at, or deleted_by, nor Metadata v2 system groups or legacy
system keys. Filter resolution likewise rejects client_id, metadata.custom.client_id, and
the lifecycle marker paths. version_id is accepted only through the dedicated sanitized
ingestion field; it is not a user-defined custom field.
Key Rotation
- Add the new key to
API_KEY_CLIENTSwith the same client id as the old key. - Deploy the updated environment and confirm both keys work during the overlap window.
- Move the DMS integration to the new key.
- Remove the old key from
API_KEY_CLIENTSand redeploy. - Confirm old-key requests return 401 and repeated old-key attempts eventually return 429.
QA-Only API Key
A dedicated QA client, qa-client, exists so release live checks and R2a/R2b verification can
exercise production without reading or writing customer data. The key maps to that client in
API_KEY_CLIENTS, exactly like any other client key, so the client resolves server-side from
X-API-Key and every request is automatically scoped to QA-only data.
| Field | Value |
|---|---|
| Client id | qa-client (stable) |
| Purpose | QA and isolation runs only — release live checks, R2a/R2b verification |
| Owner | the captain |
| May use it | the captain and delegated QA/isolation runs; never real users, and never a customer or DMS integration |
| Secret location | the deployment .env / secret store; never the repository and never a log |
The repository holds no QA key, not even a placeholder: .env.example only carries a commented
reminder. The key is generated per the procedure below and added only on the target host, never
copied from the template, and never committed or logged.
Generate the key
- Generate a strong random secret, for example
openssl rand -hex 32. - Add it to
API_KEY_CLIENTSin the deployment.env, mapped toqa-client:API_KEY_CLIENTS={"<customer-key>":"<customer-client>","<generated-qa-key>":"qa-client"} - Keep the QA entry last.
just prod-console-key(used byjust prod-upandjust prod-ui-up) resolves the first configured key, and the backfill tools default their client to the first mapped value; a QA entry placed first would hand the Console and those tools the QA client. - Redeploy (
just prod-up) and confirm the key authenticates with one request.
Rotate the key
Apply the Key Rotation steps to the QA entry specifically: add the new key
alongside the old one (both mapped to qa-client), redeploy and confirm both authenticate,
move QA/isolation runs to the new key, then remove the old key and redeploy.
Revoke the key
- Remove the QA entry from
API_KEY_CLIENTSand redeploy (just prod-up). - Confirm the revoked key returns
401and repeated attempts eventually return429.
Authorization Header Decision
The API intentionally supports X-API-Key only. Authorization is listed in CORS preflight headers so proxies and browsers can send normal preflight checks, but it is not accepted for API authentication. This avoids ambiguity between API-key auth and future bearer-token schemes.
CORS
CORS_ORIGINS must list explicit origins in production. * is rejected when DEBUG=false. Methods and headers are explicit; credentials are enabled intentionally for browser clients on approved origins.
Upload Validation
Uploads are accepted only when the extension, declared MIME type, and sniffed file content agree with the configured allow-list. Mismatches fail before the file is enqueued so workers do not process disguised binaries or unexpected content types.
OOXML Zip-Bomb Protection
OOXML files (.docx/.pptx/.xlsx) are ZIP archives and are vulnerable to zip-bomb attacks. The system checks two limits before parsing:
OFFICE_MAX_UNCOMPRESSED_BYTES(default: ~1GB) — total decompressed size across all archive entriesOFFICE_MAX_COMPRESSION_RATIO(default: 200) — ratio of uncompressed to compressed size per entry
Files exceeding either limit are rejected with a clear error before any parser touches the content.
Office MIME Type and Content Sniffing
The modern OOXML formats (.docx/.pptx/.xlsx) all share the ZIP magic bytes (PK\x03\x04), and legacy formats (.ppt/.xls) share the OLE2 compound-file magic (\xd0\xcf\x11\xe0). Content sniffing treats members of each family as compatible since the leading bytes cannot distinguish between them. The parser layer rejects a genuinely wrong inner format.
Audit Logs
Every request receives a correlation id and emits structured audit fields: request id, endpoint, method, status code, latency, client id when authenticated, hashed API-key identifier when known, and error code when present. Upload acceptance and auth failures emit dedicated audit events. Publication-authority failures on document operations, search, and job status emit a publication_unavailable event with the request, endpoint, client, and sanitized root-cause type/message when available; job status also includes the job id. Exception messages are redacted and bounded to 256 characters. Raw API keys and document contents are not logged; user-controlled strings are control-character sanitized before logging.
Go-Live Sign-Off
The OWASP checklist and go-live owner fields remain human-gated. Do not mark them complete until a named security owner, independent runbook walkthrough evidence, first-30-day success metrics, and review rhythm are recorded in the release packet.