Skip to content

Add authentication to computerd endpoints - #110

Merged
aron-cf merged 10 commits into
mainfrom
container-auth
Aug 18, 2026
Merged

Add authentication to computerd endpoints#110
aron-cf merged 10 commits into
mainfrom
container-auth

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

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:

  • The host generates a secret per workspace, passes it to the container as RPC_CLIENT_SECRET at launch, and sends it as a bearer token on /connect.
  • The daemon requires that token on every route and on the websocket upgrade. /health stays open, because the host polls it before it has a session.
  • Not providing RPC_CLIENT_SECRET disables the checks, which is how the test harnesses and local runs work.
  • The secret is stored in the durable object storage. A durable object can be restarted while its container keeps running. It must maintain the same value that container was launched with.
  • A command run by the workspace no longer inherits the daemon's whole environment, so the secret cannot leak into it.
  • The environment and the internet flag are recorded at launch. A container already running is reused only if it matches, and replaced otherwise.
  • If a container answers an unauthenticated request, the host says so in its logs.

Turning it on is one variable. Without it nothing changes:

# open, as before
computerd

# every route but /health now needs the token
RPC_CLIENT_SECRET=$(openssl rand -hex 16) computerd
curl -i localhost:8080/health                  # 200, always reachable
curl -i localhost:8080/__computerd/info        # 401
curl -i -H "Authorization: Bearer $SECRET" \
  localhost:8080/__computerd/info              # 200

A command started by the workspace now receives PATH, HOME, TMPDIR, TZ, LANG, TERM and the LC_ variables, and nothing else from the container. To pass your own, prefix it with COMPUTER_VAR_; the prefix is removed on the way through:

docker run -e COMPUTER_VAR_NODE_ENV=production ... computerd
const handle = await workspace.runtime.exec("echo $NODE_ENV", { encoding: "utf8" });
const { stdout } = await handle.result();   // "production\n"

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:

const { outcome } = await api.start({
  env: { PORT: "8080", MOUNT_POINT: "/workspace" },
  enableInternet: false,
});
// "launched"   — nothing was running
// "adopted"    — one was running with these settings, so it was reused
// "relaunched" — one was running with different settings, so it was replaced

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, /health staying 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.

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd8a96d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@110

commit: bd8a96d

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
…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
aron-cf merged commit a329cd4 into main Aug 18, 2026
19 checks passed
@aron-cf
aron-cf deleted the container-auth branch August 18, 2026 21:01
@github-actions github-actions Bot mentioned this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant