WWW Readiness — Security Audit

Audit date: 2026-02-25. Scope: what breaks when QNTX moves from 127.0.0.1 to the public internet.

Threat Model Shift

QNTX assumes the network boundary IS the security boundary. Every endpoint is reachable by the machine owner alone. On the www, every endpoint is reachable by everyone.

Enforced: Bind Address + Auth Gate

server.bind_address defaults to 127.0.0.1. The server refuses to start if bind address is non-loopback and auth.enabled is false.

# am.toml — required for non-localhost deployment
[server]
bind_address = "0.0.0.0"

[auth]
enabled = true

Code: server/init.go (safety check), am/defaults.go (default + env binding QNTX_BIND_ADDRESS).

Open Issues

P0 — Must fix before any internet exposure

No TLS. server/lifecycle.go uses ListenAndServe(). All traffic (auth cookies, attestation data, sync payloads) is cleartext. Needs ListenAndServeTLS or a reverse proxy (Caddy, nginx).

Peer sync has zero authentication. server/sync_handler.go/ws/sync accepts any WebSocket connection. An attacker can run Merkle tree reconciliation and exfiltrate the entire attestation store. Budget data (spend limits) is also exchanged. This is the single biggest exposure.

CORS origin validation uses prefix matching. server/util.go:51strings.HasPrefix(origin, allowedOrigin). With http://localhost in the allowlist, http://localhost.evil.com passes. Needs exact scheme+host+port matching.

No rate limiting. Zero rate limiting on any endpoint. Login ceremonies, API calls, file uploads, WebSocket connections — all unlimited.

P1 — Significant risk on the open internet

Missing Origin header accepted on WebSocket. server/util.go:32if origin == "" { return true }. Raw WebSocket clients bypass origin checking entirely.

/health leaks reconnaissance data. server/handlers.go:411-428 — Public endpoint returns version, git commit, build time, client count, owner name.

In-memory sessions. server/auth/sessions.go:18sync.Map. Server restart logs out all users. Under DoS this amplifies impact. Sessions need SQLite persistence.

10MB WebSocket messages x 256 buffer depth. server/client.go:40,25 — Each client can buffer ~2.5GB. A few malicious clients = OOM.

Session cookie missing Secure flag. server/auth/handlers.go:221-229 — Cookie is HttpOnly + SameSite=Lax but not Secure. Over HTTPS the cookie can still leak via HTTP downgrade.

WebAuthn RPID hardcoded to "localhost". server/auth/auth.go:46 — WebAuthn won't work on a real domain. RPID must come from config.

P2 — Should fix for hardened deployment

DNS rebinding on sync connections. server/sync_handler.go:122 — Standard websocket.Dialer resolves DNS at connect time.

SQLite database unencrypted at rest. Anyone with filesystem access reads all attestations, credentials, embeddings.

Watcher engine doesn't use SaferClient. ats/watcher/engine.go:110 — Standard http.Client on user-configured URLs. See docs/security/ssrf-protection.md.

Plugin binaries have no integrity verification. plugin/grpc/discovery.go:288-294 — Binary found by name in search paths, executed without checksum or signature.

No request body size limit on most POST endpoints. File uploads (50MB) and prose (10MB) have limits. Config updates, attestation creation, type creation do not.

Already Solid

Priority Table

PriItemEffortStatus
P0Auth required for non-loopback bindLowDone
P0TLS terminationLowOpen
P0Peer sync authenticationHighOpen
P0CORS exact matchingLowOpen
P0Rate limiting middlewareMediumOpen
P1WebAuthn RPID from configLowOpen
P1Require Origin header on WSLowOpen
P1Strip /health or auth-gate itLowOpen
P1Secure flag on session cookieLowOpen
P1Persist sessions to SQLiteMediumOpen
P1WebSocket per-client memory capMediumOpen
P2Request body limits on remaining endpointsLowOpen
P2Plugin binary signature verificationMediumOpen
P2SQLite encryption at restMediumOpen