Controller and agent runtime accept spoofed Host (DNS rebinding); LOCAL_STUDIO_API_KEY breaks the browser UI
Version: 2.1.0 (74ca85ea, main) · macOS 26.5.2, Apple Silicon, Bun 1.3.14, Node 26.5.0
Two related issues. The first is the vulnerability; the second is why the obvious mitigation isn't usable today.
1. No Host allowlist on the controller (:8080) or agent runtime (:8081)
The frontend already defends itself correctly — a spoofed Host gets 421 Misdirected Request / {"error":"Host is not allowed"}. Neither backend service does the same:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' http://127.0.0.1:8080/status
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' http://127.0.0.1:8081/api/agent/runtime/status
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' http://127.0.0.1:4783/api/settings
421 # frontend does it right
Binding to 127.0.0.1 does not mitigate DNS rebinding: after the attacker's TTL expires and evil.example resolves to 127.0.0.1, the browser treats requests to the local port as same-origin, so CORS never applies. A server-side Host check is the only thing left, and these two services don't have one.
Impact. The agent runtime is the severe case — it exposes, unauthenticated:
POST /api/agent/turn — runs an agent turn with tool access
POST /api/agent/terminal/pty/open — opens a PTY
Pi runs with the full permissions of the host user, so a page the user merely visits can reach arbitrary code execution. On the controller, an attacker can create and launch recipes; combined with trust_remote_code defaulting to true, launching an attacker-chosen HF repo is itself code execution.
Suggested fix: apply the frontend's existing Host allowlist middleware to both services, defaulting to localhost/127.0.0.1 plus the configured bind host.
2. LOCAL_STUDIO_API_KEY makes the browser UI non-functional
Setting an API key is the natural mitigation for #1, but it is not usable, for two independent reasons.
(a) The client can never obtain the key. getApiKey() in frontend/src/lib/api/connection.ts:140 reads:
return process.env.LOCAL_STUDIO_API_KEY?.trim() || "";
That variable is not NEXT_PUBLIC_-prefixed, so it is not inlined into the client bundle. In the browser it is always "".
(b) The controller wouldn't accept it anyway. use-controller-events.ts builds the SSE URL as a query parameter:
const sseUrl = apiKey ? `${apiBaseUrl}/events?api_key=${encodeURIComponent(apiKey)}` : `${apiBaseUrl}/events`;
But extractAuthToken() in controller/src/http/security-middleware.ts only reads the Authorization: Bearer and x-api-key headers — there is no query-parameter path. Confirmed live:
$ curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8080/status?api_key=$KEY"
401
$ curl -s -o /dev/null -w '%{http_code}\n' -H "x-api-key: $KEY" http://127.0.0.1:8080/status
200
So the ?api_key= branch is dead code — it cannot succeed even if the key were available client-side.
Observed result with a key set: the Status page renders Standby / offline / No model loaded / GPUs 0 / Controller logs 0 lines while a model is in fact loaded and serving. Every client-side call to the controller returns 401.
Note also that createMutatingAuthMiddleware gates all non-public paths, not only mutating ones despite its name — isPublicRequest exempts only OPTIONS and /health, and there is no isMutatingRequest check in the handler. So reads (/status, /events) are gated too. If that is intended, the name is misleading; if not, that's a third bug.
Suggested fix: accept ?api_key= for the EventSource path (which cannot send custom headers), and expose the key to the client deliberately — or, better, proxy /events through the frontend so the key stays server-side.
Workaround in the meantime
Users on a shared or untrusted network should treat the controller and agent runtime as unauthenticated local RCE surfaces and avoid browsing untrusted sites while the stack is running. Setting LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE=false removes one escalation path from the controller but does not address the agent runtime.
Controller and agent runtime accept spoofed
Host(DNS rebinding);LOCAL_STUDIO_API_KEYbreaks the browser UIVersion: 2.1.0 (
74ca85ea, main) · macOS 26.5.2, Apple Silicon, Bun 1.3.14, Node 26.5.0Two related issues. The first is the vulnerability; the second is why the obvious mitigation isn't usable today.
1. No
Hostallowlist on the controller (:8080) or agent runtime (:8081)The frontend already defends itself correctly — a spoofed
Hostgets421 Misdirected Request/{"error":"Host is not allowed"}. Neither backend service does the same:Binding to
127.0.0.1does not mitigate DNS rebinding: after the attacker's TTL expires andevil.exampleresolves to127.0.0.1, the browser treats requests to the local port as same-origin, so CORS never applies. A server-sideHostcheck is the only thing left, and these two services don't have one.Impact. The agent runtime is the severe case — it exposes, unauthenticated:
POST /api/agent/turn— runs an agent turn with tool accessPOST /api/agent/terminal/pty/open— opens a PTYPi runs with the full permissions of the host user, so a page the user merely visits can reach arbitrary code execution. On the controller, an attacker can create and launch recipes; combined with
trust_remote_codedefaulting totrue, launching an attacker-chosen HF repo is itself code execution.Suggested fix: apply the frontend's existing
Hostallowlist middleware to both services, defaulting tolocalhost/127.0.0.1plus the configured bind host.2.
LOCAL_STUDIO_API_KEYmakes the browser UI non-functionalSetting an API key is the natural mitigation for #1, but it is not usable, for two independent reasons.
(a) The client can never obtain the key.
getApiKey()infrontend/src/lib/api/connection.ts:140reads:That variable is not
NEXT_PUBLIC_-prefixed, so it is not inlined into the client bundle. In the browser it is always"".(b) The controller wouldn't accept it anyway.
use-controller-events.tsbuilds the SSE URL as a query parameter:But
extractAuthToken()incontroller/src/http/security-middleware.tsonly reads theAuthorization: Bearerandx-api-keyheaders — there is no query-parameter path. Confirmed live:So the
?api_key=branch is dead code — it cannot succeed even if the key were available client-side.Observed result with a key set: the Status page renders
Standby / offline / No model loaded / GPUs 0 / Controller logs 0 lineswhile a model is in fact loaded and serving. Every client-side call to the controller returns 401.Note also that
createMutatingAuthMiddlewaregates all non-public paths, not only mutating ones despite its name —isPublicRequestexempts onlyOPTIONSand/health, and there is noisMutatingRequestcheck in the handler. So reads (/status,/events) are gated too. If that is intended, the name is misleading; if not, that's a third bug.Suggested fix: accept
?api_key=for theEventSourcepath (which cannot send custom headers), and expose the key to the client deliberately — or, better, proxy/eventsthrough the frontend so the key stays server-side.Workaround in the meantime
Users on a shared or untrusted network should treat the controller and agent runtime as unauthenticated local RCE surfaces and avoid browsing untrusted sites while the stack is running. Setting
LOCAL_STUDIO_DEFAULT_TRUST_REMOTE_CODE=falseremoves one escalation path from the controller but does not address the agent runtime.