Skip to main content

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​

  1. Add the new key to API_KEY_CLIENTS with the same client id as the old key.
  2. Deploy the updated environment and confirm both keys work during the overlap window.
  3. Move the DMS integration to the new key.
  4. Remove the old key from API_KEY_CLIENTS and redeploy.
  5. 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.

FieldValue
Client idqa-client (stable)
PurposeQA and isolation runs only — release live checks, R2a/R2b verification
Ownerthe captain
May use itthe captain and delegated QA/isolation runs; never real users, and never a customer or DMS integration
Secret locationthe 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​

  1. Generate a strong random secret, for example openssl rand -hex 32.
  2. Add it to API_KEY_CLIENTS in the deployment .env, mapped to qa-client:
    API_KEY_CLIENTS={"<customer-key>":"<customer-client>","<generated-qa-key>":"qa-client"}
  3. Keep the QA entry last. just prod-console-key (used by just prod-up and just 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.
  4. 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​

  1. Remove the QA entry from API_KEY_CLIENTS and redeploy (just prod-up).
  2. Confirm the revoked key returns 401 and repeated attempts eventually return 429.

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 entries
  • OFFICE_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.