Skip to content

Security: hasso5703/dgx-spark-qwen38

Security

SECURITY.md

Security policy

What this box trusts, what it does not, and how to report a hole in that line.

What this project is, security-wise

A serving stack one operator installs on a machine they own and administer: a Docker engine behind a Python proxy, a cockpit web UI that reaches systemd through an exact-argv sudo allowlist, and a relay, installed with the cockpit when opencode is there, that puts opencode's web interface behind the cockpit login. There is no telemetry and no phone-home: every byte that leaves the box is a download the installer names (Hugging Face, the image registry, and GitHub: the one-liner's clone and the pinned opencode release) or a request you sent. One exception, added in v1.15.2 and named here because it is one: the cockpit asks GitHub's public releases endpoint whether a newer release exists, so that a box running an old version is told rather than left to find out. Once it has an answer it asks again six hours later; a check that fails is retried after one minute, then two, four, eight and so on up to six hours. It sends nothing but the cockpit's version in a User-Agent, it reads a public URL that needs no credential, and COCKPIT_UPDATE_CHECK=0 turns it off, after which the cockpit says nothing about releases at all. That includes the typed decisions route (POST /v1/systemone, v6.19): it speaks the wire contract of a hosted service, and it is answered by the engine on this box and nothing else; the proxy calls no address but its upstream.

Two things the typed-decisions route is careful about, both found by a review of it on 2026-09-19 and both fixed before it shipped. The proxy decodes the request path once, before any check reads it, because the engine routes on the decoded path: matching the raw string let /%76%31/chat/completions walk past the per-client identity wall and past the oversize guard with the engine's own key attached. And the state of a typed decision, which is third-party text by design, is fenced with a token drawn at start-up: without it a state could write the framing of the question itself and be answered instead of judged.

Reporting

Report privately, not as a public issue (a public issue is a disclosure with no fix window). Email basbunarhasan@gmail.com with as much of this as you can: the version (git rev-parse HEAD or the release tag), the component (proxy, cockpit, relay, installer, sudoers surface), what you sent, what came back, and what you believe the boundary break is. Expected first response: 7 days, with patches landing as ordinary commits once the fix is public. GitHub's private vulnerability reporting tab is an acceptable channel once enabled for the repository.

The trust boundaries, as they actually exist

The keepalive proxy (keepalive-proxy.py, :30001) is the only component that reads bytes it does not control: every client request. Its guards are its contract: bodies over MAX_BODY_BYTES (256 MiB) get 413 before a byte is read, a lying Content-Length cannot decide an allocation, tool schemas are sanitized at bounded depth and only for patterns Python cannot compile, image parts are priced from their own headers, and the corruption tripwire aborts a decode that emits runs of token id 0. Fuzzed and state-machine tested (see TESTING.md); a crash you can reproduce in the proxy is a bug worth a private report.

Several of those guards exist because the engine behind it does not survive the request, and a denial of service that costs one HTTP call is worth naming as such. A prompt past the KV pool is queued and never admitted, and from then on the engine generates nothing for anyone until it is restarted (measured on this box, 2026-08-29). A logprob request past the vocabulary raises selected index k out of range inside the scheduler and the server is gone for every client until it is restarted, about nine minutes here (sglang#40076, open). A stop_token_ids or input_ids entry past the vocabulary indexes a scatter_add_ or the embedding out of bounds, which on CUDA is a device-side assert with the same blast radius, and n expands a list before scheduling with no bound at all (sglang#31597, whose two fixes were closed without being merged). Every one of those fields is declared unconstrained in the served release, checked in the image, and all of them are reachable from an ordinary chat request. One more is reachable from /generate: on a batch that mixes a request asking for token_ids_logprob with one that does not, the 27B build served until v1.22 (v0.5.19) fills a bare list for the one that did not ask and its scheduler dies on it (sglang#34719, open; refused since v1.22.13). v0.5.21 fills an empty tensor there, and the issue's reproduction no longer kills its scheduler (20 rounds on the reference box, 2026-10-10); while the issue is open the field stays refused.

The proxy refuses all of them with a 400. That protects the clients that go through it, and nothing else. Whatever reaches the engine's own port with the serving API key reaches the scheduler with none of these guards. Since v1.17 the engine binds 127.0.0.1 by default, so on a box installed that way its port answers on the box only and the proxy is the one door from the network. One setting still puts the engine itself on the network: ENGINE_BIND=0.0.0.0, given to ./install.sh, or to ./run.sh, the foreground path of --no-service, which has no proxy at all (and serves 127.0.0.1 without it, like the units, since v1.18.7). With it, anything on the LAN or the tailnet that holds the key can reach the engine directly and end it, exactly as it could before these guards existed: the key is the boundary there, not the proxy. Nothing is lost by the default: the proxy is a full pass-through of every route the engine serves, so a client that used :30000 from another machine uses :30001 and gets the guards with it. ENGINE_BIND=0.0.0.0 restores the old behaviour, PROXY_BIND does the same for the proxy, and an installed choice is kept across updates in both directions. Since v1.36.0 the engine bind that only an install before v1.17.0 put on every interface, and that nothing records as chosen, moves to 127.0.0.1 at the next update instead: the update says so before its first step, with ENGINE_BIND=0.0.0.0 ./install.sh to keep it, and a box that set it since keeps it.

The key is checked before a body is read (since v1.22.13, proxy v6.34). Without the identity wall, the engine's key is the proxy port's one gate, and the proxy applies it itself, with the engine's own rule and the engine's own 401, before it reads a byte of the body. Until then a caller with no key, or a wrong one, had its body read whole (up to MAX_BODY_BYTES) and, when it was large, counted by the engine on the proxy's own key before the engine refused it. Paths under /health, /ready and /metrics stay open, as the engine keeps them (/ready since v1.26.1).

The cockpit (dashboard/, :30090) runs as the user who installed the box, and that user is already root-equivalent: install.sh requires it to be in the docker group, and a member of that group can start a container with the host's / mounted. The NOPASSWD sudoers lines rendered from dashboard/sudoers-cockpit.template add no privilege to that, and they are no boundary either: two of them install a file the user writes (~/.config/qwen38/*.switch-stage) into /etc/systemd/system, and with daemon-reload and restart allowed beside them, whoever writes that file runs what it says as root. So the cockpit's user, its API key and a session on it are root on this machine, and the defences that count are the ones in front of it: the key, the bind, the session cookie. The allowlist is still exact argv with no wildcards (a glob on sed arguments is root without any file to write), and two CI steps hold it: no * on any allowlist line, and every privileged call in switch-model.sh cross-checked against the template. The cockpit process runs unprivileged; login is the serving API key; a per-session HMAC cookie gates everything else; the diagnostic bundle masks the API key in every member, and a test builds a real tarball and greps it rather than trusting the masking code.

The agent relay (dashboard/agent_relay.py, :30091 by default) is the only thing that reaches opencode serve: it requires a valid cockpit session cookie, refuses foreign origins, strips caller headers the relay owns, and forwards over HTTP with Basic credentials that never leave the machine (opencode binds 127.0.0.1). Its boundary properties (origin gate, cookie names, header hygiene) are property-tested.

The installer (install.sh, dashboard/install-*.sh) runs things you sudo. Every pinned byte is hash-verified: images by digest, checkpoints by pinned revision, the opencode release by its sha256. The image and video lanes (--with-image, --with-video) are the parts with unpinned bytes: their SGLang wheel, source commit and checkpoint revision are pinned, but pip resolves the wheel's dependency tree from PyPI at install time, with no hash checked. The speech lane (./install-speech.sh, opt-in) pins llama.cpp's commit, its models by sha256, and every package of its venv by exact version, wheels only, also without hashes. The engine enforces its key, read from a file created 0600 and handed to it through SGLang's --config, so it is in no process's command line and the chat template and launcher are regenerated from the repo on every install.

Assumptions that are design choices, not vulnerabilities

Read these before reporting; a report against one of them is a design discussion, not a disclosure.

  • Plain HTTP by default. The cockpit and the relay serve HTTP only, and so does the proxy until it is given a certificate (QWEN38_TLS_CERT, proxy v6.16, see docs/clients.md): all three are meant for loopback or a tailnet. The cockpit says in its journal when it binds anything but loopback, and with no key file no login succeeds. TLS in front of the cockpit and the relay is the operator's reverse proxy. If you need to expose any of it to a LAN you do not control, that is the design's stated edge, not a hole.

  • One API key, one trust realm, by default. Every client that holds the key is the same principal: same quota, same ceiling, same lane. The proxy can name clients instead (QWEN38_CLIENT_KEYS_FILE, proxy v6.16: one bearer per client, a 401 for an unlisted one on every route but /health, a 403 for a listed one on the engine's operator routes since 2026-10-16, the wall signing what it relays with the engine's own key: a client key generates, counts and reads, the label on every journal line of its requests), and naming is all it does: there are no per-client quotas or ceilings, and the engine behind it still has one key.

  • AGENT_AUTO=1 is deliberate escalation. It sets opencode to allow every tool call in the Agent view, behind the cockpit login, on this machine, by the operator's own choice. The explicit deny rules of your own opencode.json still apply.

  • The abliterated targets are a policy choice. uncensored, uncensored-fp8 and flash-uncensored exist as pinned checkpoints because the operator asked for them; nothing in the serving stack filters or vets what they answer.

  • /metrics needs no key. Every text lane passes the Prometheus flag, and the engine serves /metrics without the API key (checked on the reference box: 200 with no key, where /v1/models answers 401). Since v1.17 the engine port is on loopback, so from another machine the counters are read through the proxy on :30001, which relays that route like any other, with no key either; with the per-client identity file set, it needs a listed key like every route but /health. Request rates and queue depths are therefore readable by anything that can reach the proxy port. That is the port's existing trust model (a trusted network by design, see the plain HTTP edge above); the metrics endpoint adds counters to it, not a new surface to authenticate against. The image and video lanes export no metrics.

  • The speech lane's routes take no key. Like the image and video lanes, the speech lane (opt-in, :30024) serves its routes with no key on loopback, and neither the cockpit nor the proxy relays them: programs on the box reach them, another machine through an SSH tunnel. SPEECH_BIND puts them on another address, and the installer says what that costs: anyone who reaches it can transcribe and speak with the box's CPU. Its recognizer (:30025) binds loopback whatever is set.

  • Each engine opens its own coordination port on every interface. The API of a lane binds where it is told (loopback by default), but the engine process also runs PyTorch's distributed store, and torch binds that listener to all interfaces with no setting to narrow it. Measured on the reference box on 2026-09-30: the flash lane's scheduler listens on *:45385 (a random port, new at each boot) beside its API on 127.0.0.1:30000, and a TCP connection to it succeeds on the LAN and the tailnet addresses; its four other listeners are on loopback. The video lane does the same on *:30005, with four gloo ports on the LAN address (measured 2026-09-29, again on 2026-09-30). The port takes no key and serves no model and no data, but PyTorch states that its distributed features are not built for untrusted networks: a hostile connection can disturb the engine, a denial of service. It sits inside the trusted-network edge above, like the plain HTTP ports. To see them on your box while a lane serves: sudo ss -ltnp | grep -v 127.0.0.1. On a LAN you do not control, firewall the box to the ports you use before anything else; with ufw (inactive on the reference box):

    sudo ufw allow in on lo
    sudo ufw allow in on tailscale0      # the cockpit, the proxy and the relay over the tailnet
    sudo ufw allow 22/tcp                # SSH from the LAN, if you use it
    sudo ufw default deny incoming
    sudo ufw enable

    Docker does not bypass this here: every lane runs on the host network or natively, so no port of theirs goes through Docker's own rules. Allow 30001/tcp too if LAN clients use the proxy.

  • The cockpit assumes one admin user. The sudo allowlist covers this repo's argv, but a hostile local user with your shell can do what you can do: this box is yours, and it is not a multi-tenant host.

Out of scope

SGLang and the serving images themselves (report upstream, tell us too if it affects a pinned digest), model behavior and prompt injection into agents (opencode's permission model is the boundary), and anything reachable only with root or physical access.

There aren't any published security advisories