This document captures the operational requirements that the cix-server codebase assumes but does not enforce on its own. Read it before exposing the dashboard to users beyond a single trusted operator.
The server reads X-Forwarded-For (first hop) when present and uses the
result for two things:
- Audit metadata — stored as
sessions.last_seen_ipandapi_keys.last_used_ip. - Per-IP login rate limit key — see "Login brute-force resistance" below. The per-(IP, email) key still binds independently of the IP source, so password guessing against a known account is rate-limited regardless; only the global per-IP sweep cap depends on the header being trustworthy.
This makes the trusted-proxy posture load-bearing for security, not just for audit honesty. Two safe deployments:
- Reverse proxy in front (Cloudflare / Caddy / nginx / Traefik / ALB):
configure the proxy to replace the inbound
X-Forwarded-Forwith the real client IP, not append to it. DropX-Real-IPif you don't need it. This is the recommended posture for any internet-exposed deployment. - Direct exposure on a trusted network (LAN / VPN only): nothing
forwards
X-Forwarded-Forfor you, so an attacker who can reach the port can also forge the header. The per-(IP, email) cap still slows password guessing, but the global per-IP cap is bypassable. Acceptable on a trusted network, never on the open internet.
Example for nginx:
location / {
proxy_set_header X-Forwarded-For $remote_addr; # replace, not append
proxy_set_header Host $host;
proxy_pass http://cix-server:21847;
}The session cookie's Secure attribute is set automatically when the
request arrives over TLS (r.TLS != nil). For any deployment beyond
localhost, terminate TLS in front of the server and ensure the server
sees TLS-marked requests so the cookie is not sent in cleartext.
If you front the server with a TLS-terminating proxy that downgrades to
plain HTTP for the upstream hop, the auto-detection will return false and
Secure will be omitted. Two fixes:
- Terminate TLS directly in cix-server (drop the proxy).
- Or configure the proxy to make the upstream hop look TLS-marked — the details vary; consult the proxy docs.
If you put an authenticating proxy in front of cix (Cloudflare Access,
oauth2-proxy, Authelia, an SSO/mTLS-terminating LB), the browser dashboard
passes it via interactive SSO, but the cix CLI and AI-agent tooling send only
the cix Bearer and get bounced at the edge (302/403) unless their source IP is
allow-listed. The CLI can attach custom request headers (e.g. a Cloudflare
Access service token) on every request to satisfy the edge layer in addition to
the cix Bearer — the proxy validates and strips them, so cix needs no knowledge
of the proxy. See
CLI_CONFIG.md → Custom request headers.
POST /api/v1/auth/login is rate-limited in process (internal/httpapi/loginlimiter.go):
- 5 failed attempts per (IP, email) per 15 minutes — slows guessing against a known account. Cleared on a successful login so a user who fat-fingers their password a few times is not stuck.
- 60 attempts per IP per minute — slows horizontal sweeps across many emails from a single source. Not cleared on a successful login.
This is a single-process limiter; multi-replica deployments do not share
state. If you scale out, put a shared throttle (Redis, your reverse proxy)
in front of /api/v1/auth/login or accept that the per-replica caps are
the floor.
A request-body middleware rejects oversize payloads up-front:
- Default cap: 1 MiB for every endpoint.
- Indexing cap: 64 MiB for
POST /api/v1/projects/{path}/index/files, which legitimately receives JSON-encoded source from a batch of files. At default config (batch=20, max-file=512 KiB) a real payload is ~11 MiB; the cap also covers operator-tuned worst case (batch=50 × max-file=1 MiB ≈ 55 MiB) with headroom.
The cap fires on Content-Length (clean 413) and on chunked-transfer
overflow (the JSON decoder fails and the handler returns 422). If your
indexer batches need more than 64 MiB, raise indexingMaxBodyBytes in
internal/httpapi/middleware.go rather than asking operators to disable
the cap.
On a fresh database the server reads CIX_BOOTSTRAP_ADMIN_EMAIL and
CIX_BOOTSTRAP_ADMIN_PASSWORD and creates the first admin row, marked
must_change_password=1 so the operator must change the password on
first login.
- Both env vars must be set together; setting only one is a fatal startup error.
- Once the users table is non-empty, the env vars are ignored. Rotating the bootstrap password by editing the env has no effect on a running installation — go through the dashboard or directly through SQLite.
- The bootstrap path is not transactional. If two server instances start simultaneously against the same fresh database, one of them will fail with a UNIQUE-constraint error from the duplicate email. This is intentional (better to fail loud than silently create two admins) but operationally surprising under HA-style deployments — start a single instance first, then scale out.
The server enforces only len(password) >= 8. There is no complexity
rule, no breached-password dictionary check, no rotation prompt.
For internet-exposed deployments, choose admin passwords accordingly: a 20+ character random passphrase from a password manager beats anything the server could enforce. The rate limiter above caps the damage of weak passwords at ~480 guesses per (IP, email) per day.
A user who forgets their password cannot reset it themselves. Recovery options (in order of preference):
- Another admin issues
POST /api/v1/admin/userswith a new initial password andmust_change_password=1, then disables the old account. - Direct SQLite access to clear
users.disabled_atand resetusers.password_hash(use bcrypt cost 12).
Plan for this when designating admins — keep at least two so an admin reset never requires DB-level intervention.
API keys inherit the full permissions of their owning user. A non-admin user's key can do anything that user can (read their own projects + projects/workspaces shared to a view-group they belong to, write to projects they own); an admin's key can do anything an admin can. There is no read-only scoping, no per-project scoping, no expiry.
Roles in the system today are admin and user. For automated
callers (CI, scripts) that only need to read shared workspace search
results, create a dedicated user-role account, add it to the
relevant view-groups, and issue keys from that account. Rotate keys
via DELETE /api/v1/api-keys/{id} rather than reusing them.
If your threat model needs any of these, build them in front of cix-server or accept the risk:
- CSRF tokens. Protection relies on the cookie's
SameSite=Strict+HttpOnlyattributes, which modern browsers honour. There is no separate token to validate. - CORS. No
Access-Control-Allow-*headers are emitted; same-origin is the assumption. - WAF / IDS. No IP allowlisting, no anomaly detection. Use your reverse proxy or a host-level firewall.
- Multi-tenant tenant isolation. cix-server has a single shared
user/role/group namespace (introduced in
e275c4a, server v0.6.0). Within it: local projects are owned by their creator and private to the owner + admins; workspaces and external projects (admin-added GitHub repos) are admin-administered and shared to view-groups explicitly. Non-admins see only what they own or what is shared to a group they belong to. This is single-tenant-with-row-level-ACL, not true tenant separation — admins still see everything and pass through every ACL. If you need hard tenant boundaries, run separate cix-server instances per tenant.