Security¶
Deployment boundary¶
millivolt is a trusted-operator tool, not a multi-user security boundary. It
has no authorization roles or tenant isolation. The whole dashboard is gated
by the shared MILLIVOLT_OPERATOR_TOKEN environment credential and denied
while it is unset (see operator access):
there is one operator identity, no per-user accounts and no audit trail of
who acted. Only the GET /healthz liveness and GET /readyz readiness probes,
the POST /session login handshake, origin-root brand/PWA assets and transparent
inference stay open at the application gate; the canonical open-path list is
operator access. The upstream API key is
forwarded for inference; it does not secure the dashboard or administration.
The supplied listen address binds all interfaces. Prefer an explicit loopback listen for local use. Before network exposure, restrict access with network controls and an appropriately authenticated ingress. Protect the entire operator surface, including dashboard/bootstrap/live records, SQL, Debug, exports, Settings, Pause, Limits, purge and restart. Inference clients can also change provider-wide limits through request headers.
For Docker, publish on host loopback, for example -p 127.0.0.1:8080:8080.
A non-root container and a public image do not authenticate callers or make a
publicly reachable operator listener safe. Protect persistent config/data
volumes as sensitive state, including backups and debug captures.
Browser same-origin mutation checks and framing denial are defense in depth
behind the operator credential, not a substitute for it. The live metrics feed
does not opt into cross-origin
browser sharing; the dashboard uses a same-origin EventSource authenticated
by the HttpOnly session cookie. This does not
prevent direct non-browser reads or secure a publicly reachable listener.
Explicit non-browser calls remain supported; gated endpoints need the
Authorization: Bearer credential or a session cookie minted through
POST /session. The /mcp endpoint is the exception: it requires the
operator credential as a Bearer header and refuses a session cookie alone with
403, because the caller's credential is forwarded on its internal calls.
Upstream destinations and credentials¶
X-Proxy-Base-URL selects an HTTP(S) destination. An unrestricted caller can
therefore make the proxy contact destinations reachable from its host.
allowed_base_urls constrains accepted upstream targets: an entry admits the
same scheme, host and port (with the scheme's default port filled in) and that
entry's path and its children. A port-less entry therefore admits the default
port only, not every port on that host. Use trusted destinations and
appropriate egress controls. This is not a DNS/IP isolation sandbox.
The proxy does not follow upstream redirects.
Ordinary metrics group credentials by SHA256 rather than retaining the raw key. Known secret and configured authentication headers are redacted in retained metadata/captures without changing forwarded headers. This does not sanitize arbitrary text: request/response bodies, URLs, error messages, model names or user-supplied labels can contain sensitive data. Preview capture and Debug capture are opt-in, but enabling them can persist that data.
Client identity is a claim, not an authentication¶
X-Proxy-Client (and the classified client derived from SDK and user-agent
headers when it is absent) is chosen by the caller. Nothing verifies it: a
request that sends another client's label is treated as that client by every
feature that scopes on client - request_overrides (so it inherits that
client's header and token-ceiling rules), sub_conversations (so it gets that
client's field mapping and strip list), Debug capture sessions, pause holds
including the "new clients" comparison, and the Cursor parked-run scope. The
recorded client, and therefore dashboard grouping and the client filter, also
follow the claim.
The recorded client address (client_ip) is likewise a hint. It is the socket
peer, so behind an ingress it is the ingress address; X-Forwarded-For is
never read for the record. Do not treat client_ip as a verified source for
rate limiting, allowlisting or attribution outside the proxy. The operator
gate's brute-force limiter is the one exception: it keys on the rightmost
X-Forwarded-For element (the address the immediate hop appended) and falls
back to the socket address, so it distinguishes clients behind a correct
ingress but a directly reachable caller can still pick its own bundle.
The example ingress trusts exactly one loopback hop (set_real_ip_from
127.0.0.1, deploy/nginx.conf) and its allow lists
evaluate that rewritten address. A caller that can already reach the loopback
listener therefore chooses the address the lists see: they separate remote
networks from each other and from local callers, not local callers from each
other. Treat a location allowlist as reachability control for a network, not as
identity.
Treat client scoping as a way to separate your own tools and traffic, not as a
security boundary between mutually untrusted parties. A proxy reachable by
someone who should not act as another client needs that separation enforced
outside these labels: distinct allowed_base_urls, distinct upstream
credentials per caller, or a separate proxy instance. Credential-class header
names are refused in request_overrides and redacted in retained data, so a
claimed label cannot by itself move an upstream secret; the exposure it does
create is the policy attached to the label, not the credential behind it.
Token-refresh handback responses contain credentials in the error body for the client to adopt. Do not publish them, browser exports, databases, backups, local config, login-script output or screenshots containing private history.
Provider favicons can make browser requests to Google's favicon service, exposing
the provider hostname and the browser's network address to that service.
referrerpolicy="no-referrer" omits the dashboard referrer, not the requested
hostname. Hostname syntax checks do not establish that every qualified provider
label is public. The documentation capture workflow blocks all external browser
requests; that isolation is not the ordinary dashboard's network policy.
Administrative data and resource limits¶
SQL is restricted to read-only queries with execution/output limits, not a sandbox for adversarial resource consumption. Debug has bounded capture sizes and retention, but captured bodies remain sensitive. Exports and backups can outlive those retention settings.
Metrics storage is asynchronous and best-effort. Queue overflow/write failures can lose records; this is not a tamper-proof, complete compliance audit log. Do not rely on the dashboard as the sole security or billing record. See operations and limitations.
Code scanning¶
GitHub code scanning (CodeQL) reports on pull requests and the default branch. Alerts are triaged against the deployment boundary, not dismissed for convenience. True positives are fixed and covered by regression tests; accepted false positives record their rationale in the alert instead of being hidden by a broad path exclusion. The current categories and their trust-boundary evidence:
- Operator-only read-only SQL (
internal/storage/query_admin.go). The query console sits behind the operator gate, accepts one SELECT, and enforces execution time, row, column and result-byte limits. The operator is already trusted with the database, so this is not an unauthenticated SQL surface. - JSON, SSE and gzip response writers. The operator surfaces behind the gate
encode the proxy's own output, and framing and compression state are bounded
by the response-size guard. Two writers are intentionally reachable without
the operator gate: the
/healthzand/readyzprobe JSON and the transparent inference SSE relay, which streams the upstream response to a client that supplied its own provider credentials. Neither exposes operator data (the readiness reason names queue depth and flush failures only), so this category is not an authentication bypass. - Bounded allocations (
cmd/proxy/mcp.go,internal/sse/frames.go). The MCP recorder cap is the fixedmcp.MaxResponseBytesread budget (64 MiB) plus one byte, not a configurable limit. The SSE frame's requested size is the marshaled payload length plus the fixed framing overhead;slices.Concatmay round the capacity up to the allocator's size class. The payload is bounded by the relay's scanner and encoder frame caps and the response-size guard. Input is rejected at those bounds before the sized allocation, so neither is an attacker-controlled unbounded size.
The operator-cookie and integer-conversion findings were true positives and are
fixed: the operator session cookie now sets Secure when the request arrived
over TLS or through an https-forwarding ingress; the jsonnum, config, and
throttle paths now parse or bound at the target width, with regression tests.
The allocation findings are closed by construction, using overflow-safe capacity
owners. The operator SQL console is an accepted boundary, and the transparent
response-writer wrappers are false positives from interface fan-out; their
alerts are dismissed with a recorded reason. Dismissing or resolving a
code-scanning alert is a maintainer action in the GitHub security UI; this note
records triage rationale and does not dismiss any alert automatically.
Scope¶
In scope: the proxy, dashboard, MCP server, container image, and repository automation. Out of scope: findings that require a compromised host, an operator who already holds the credential, a misconfigured ingress, or a third-party provider. A report that depends on the documented trusted-operator boundary must show how that boundary is crossed.
Safe harbor¶
The maintainers will not pursue or support legal action for good-faith research that stays within this scope, avoids privacy violations, service disruption, and data destruction, and allows reasonable time to fix the issue before disclosure.
Reporting a vulnerability¶
Do not publish credentials, private request content, or actionable exploit details in a public issue.
Use the repository's enabled private vulnerability reporting channel.
Include the affected revision, deployment assumptions, impact, and a minimal reproduction using neutral local fixtures. Test only systems you own or are explicitly authorized to assess.
Supported versions¶
Security fixes target the latest published release. The 0.x series is in
active development; older releases are not backported.
| Version | Supported |
|---|---|
| Latest published 0.x release | Yes |
| Older releases | No |
Disclosure process¶
Reports are acknowledged on a best-effort basis; no response-time SLA is promised. Maintainers will validate the report, prepare a fix, publish a GitHub security advisory, and credit the reporter unless they prefer otherwise. Please allow time for a coordinated fix before public disclosure.
Dependency review and a scheduled Go vulnerability scan run in CI. A clean scan is not a security guarantee, and neither replaces a private report.
GitHub documents private vulnerability reporting.