Add authentication to computerd endpoints - #110
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: bd8a96d The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
Every spawned command received the daemon's whole environment. That carried the daemon's own configuration into the workspace, and it means any secret placed in that environment reaches any command the workspace runs. A command now inherits a named set: PATH, HOME, TMPDIR, TZ, LANG, TERM, the LC_ family, and anything prefixed COMPUTER_VAR_, which arrives with the prefix stripped so COMPUTER_VAR_NODE_ENV becomes NODE_ENV. A prefixed value is applied last and may replace one of the standard variables, which is how an operator pins a toolchain onto PATH. The runner's configured environment and the per-call environment still layer on top. An allowlist rather than a denylist, so that adding a variable to the daemon does not expose it by default. PATH is the one that had to survive. /bin/sh falls back to a compiled-in default covering /usr/bin, so dropping it leaves standard tools working while hiding anything an image installed elsewhere, which is how images usually ship language runtimes and vendored toolchains. This narrows what a command sees. A workspace reading some other inherited variable will stop finding it and needs the prefix.
POST /connect makes the daemon dial a caller-supplied address and serve a full WorkspaceRPC session over it, which carries filesystem access and shell execution. Nothing authorized the request, so anything able to reach the port could point that session wherever it liked. Setting RPC_CLIENT_SECRET now requires it as a bearer token on every route and on the websocket upgrade. The check runs before routing, so an unauthorized caller cannot map the surface, and it compares with timingSafeEqual after a length check, since timingSafeEqual throws on mismatched lengths and the length is not worth leaking either. Readiness stays open. The host polls /health before it holds a session, and gating it would turn a bad token into what looks like a container that never started. Leaving the variable unset disables the checks, which is what the container harnesses and local runs rely on. The daemon reads the secret once and deletes it from its environment. The exec allowlist already keeps it away from spawned commands; removing it means a later code path that spreads process.env cannot reintroduce the leak.
The container now requires a bearer token on its HTTP surface when RPC_CLIENT_SECRET is set. The host generates one, hands it to the container in that variable at launch, and presents it on POST /connect. The secret is persisted next to the container runtime identity rather than generated per call, because a durable object can be reconstructed while its container keeps running. start() does not relaunch in that case, so the container still holds the environment from its original launch; a fresh secret each incarnation would not match and every request would be refused, breaking the reconnect POST /connect exists to serve. Persisting also covers a replaced container, which is launched with the value already stored. Thirty-two hex characters from getRandomValues, carrying 128 bits. ContainerRuntimeInfo gained the secret so it reaches the backend by the same route as the runtime id, which the backend already threads from start() through to the connect request.
Add RPC_CLIENT_SECRET and COMPUTER_VAR_ to the environment tables, note on the route table that /health is the only route reachable without the secret, and say in the daemon readme what an unauthorized request gets. Changeset entries for both, since each is a change a consumer may have to act on: a workspace reading an inherited variable now needs the prefix.
The environment and the outbound-internet flag can only be set when a container process starts. start() accepted both, discarded them when a container was already running, and returned as though they had applied. Anything that pre-started a container therefore decided the configuration for whichever workspace later adopted it, and nothing detected the disagreement. The warm pool in examples/think-compare-runtimes is the case in point. It starts containers itself, so the shared secret was never injected and the daemon ran with its HTTP surface unauthenticated while the host sent a bearer token it was happy to ignore. The internet flag has the same shape: the pool hardcodes it on, and a workspace configured for the none or http-gateway egress modes would have inherited direct internet access regardless. The two launch inputs are now one ContainerLaunchSpec, and each launch records what it used beside the runtime identity: the internet flag, and a digest of the environment rather than the environment itself, since containerEnv is consumer-supplied and may hold their own secrets. Adoption compares, and relaunches on any difference. A container started outside this API leaves no record, which reads as a mismatch, so it is replaced rather than trusted. That restores the guarantee even for a caller that never adopts the new entry point. ContainerRuntimeInfo reports which of launched, adopted or relaunched happened, and a relaunch keeps the durable secret so the replacement receives the value the host already holds. setInactivityTimeout joins the interface so a warm pool has no reason to reach past it to ctx.container.
aron-cf
force-pushed
the
container-auth
branch
from
August 18, 2026 17:37
5abd365 to
0816c68
Compare
…rkspace API The pool started containers with ctx.container.start() and an environment it built itself, which skipped everything the workspace API adds. The shared secret never reached the container, so its HTTP surface stayed unauthenticated while the host sent a bearer token it ignored, and the hardcoded internet flag became whatever the adopting workspace inherited. Warm starts now go through the API, so the launch carries the secret and is recorded for the adoption check. The environment and the internet flag travel together as a ContainerLaunchSpec, which is also what the adopting workspace compares against. The retry loop calls start() unconditionally now. It adopts a container already running with the same spec, so there is nothing left for the caller to decide from a `running` flag. A pool still cannot know the egress policy of the workspace that will adopt a container, so enableInternet has to agree with it by configuration. Disagreeing now costs a relaunch on adoption rather than the policy. ContainerLaunchSpec and ContainerRuntimeInfo are exported from the container entry point, since a consumer that pre-starts containers needs both.
The boot sequence described start() as a call straight to the Cloudflare Containers API. It goes through WorkspaceContainerAPI, which adds the shared secret and records what the container was launched with, so a container found already running is adopted only when it matches and relaunched when it does not. Changeset entry for the interface change, since a consumer that pre-starts containers has to move to the new shape.
Injecting the secret at launch cannot prove the running image honors it. A container built before the daemon understood RPC_CLIENT_SECRET ignores the variable and serves every route, and that is invisible from the host: the bearer token goes out and the container is content either way. After readiness, the backend now makes one unauthenticated request and refuses to connect unless it comes back 401. A container that answers it is not authorizing anyone, and handing it a session while believing otherwise is worse than failing: recycling a container or image that predates the secret is the cost of the upgrade. The probe target is /api rather than a diagnostic route, so the check does not depend on which of those a given build exposes. An enforcing daemon answers 401 before it looks at the route; one that is not answers whatever that route says for an unauthenticated GET. The request is bounded by healthProbeTimeoutMs, clamped to what is left of the connect deadline, matching every other request this file makes to the container. Unbounded, a container that accepted the connection and then stopped serving would hang the connect past its own timeout, and a merely slow one would consume the budget the upgrade wait needs and surface as an upgrade that never arrived. A probe that cannot complete is still allowed through. A timeout is not evidence that the container is unauthenticated, and the readiness loop is what decides whether it is alive.
The check compared the literal prefix "Bearer ", so a client sending "bearer <secret>" was refused with a 401 that looks like a wrong token. The HTTP grammar makes the scheme token case-insensitive and allows more than one space before the credentials, so all of those spellings are valid requests. The scheme is now compared case-insensitively and the credentials are taken from whatever follows the first space, trimmed. Only the credentials are compared byte for byte, in constant time, as before. The host in this repository always sends "Bearer", so this only affected clients written against the documented header.
The container had to present a bearer token to reach the daemon, but the reverse direction was open. connect() arms a slot for the container's outbound upgrade and hands its session to whatever arrives first, and that endpoint is reachable from inside the container through the egress interceptor. Any command the workspace runs could win that race. Winning it does not hand the caller the durable object's authority, since the durable object is the client of that session and exposes nothing. It does something worse in one respect: the impostor becomes the container this workspace trusts, so pushOnce ships the workspace's file contents to it, pullOnce takes its entries into the authoritative store, and exec sends it the agent's commands and believes its output. The daemon now presents the secret it was launched with on the outbound dial, and handleFetch requires it before accepting the socket. Both ends already hold that secret: the host generates it and sets it at launch. The comparison on the host side walks every byte rather than stopping at the first difference. crypto.subtle.timingSafeEqual would be the primitive to reach for, but the SubtleCrypto this package compiles against does not declare it. An absent expected secret refuses everything. handleFetch only runs after connect() has recorded what it launched the container with, so an absent one means the upgrade arrived without that having happened. This gap predates the branch; the surrounding change is what makes the in-container party the party being defended against.
aron-cf
force-pushed
the
container-auth
branch
from
August 18, 2026 20:57
0816c68 to
bd8a96d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The daemon inside the container served its whole HTTP surface to anything that could reach the port. Any code running in the container could point the session somewhere else.
Now:
RPC_CLIENT_SECRETat launch, and sends it as a bearer token on/connect./healthstays open, because the host polls it before it has a session.RPC_CLIENT_SECRETdisables the checks, which is how the test harnesses and local runs work.Turning it on is one variable. Without it nothing changes:
A command started by the workspace now receives
PATH,HOME,TMPDIR,TZ,LANG,TERMand theLC_variables, and nothing else from the container. To pass your own, prefix it withCOMPUTER_VAR_; the prefix is removed on the way through:Any other environment variable will not be reachable from the exec process.
If you start containers yourself, for example from a warm pool, the launch settings travel together and
start()reports what it did:Settings cannot be changed on a live container, so a mismatch replaces it. A container started without going through
start()has no record of its settings and is replaced for the same reason, so a pool that has not been updated still leaves the workspace with a container it can trust.Tests cover a token accepted and refused, a token of the wrong length refused,
/healthstaying open, no checks when the variable is unset, the secret being absent from a command's environment, and each of the three launch outcomes. The daemon also reads the secret once and drops it from its own environment, so a later code path that copies the environment cannot reintroduce the leak.