Keep the private-hosts switch out of production - #198
Conversation
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS was read as a plain equality with no
environment gate, and .env.example shipped it live, so a deployment inherited it
by copying the file. That is the ordinary path rather than an unusual one.
It lifts more than browsing: app.ts hands the same value to createAgentRoutes,
and target.ts:257 is an early return { allowed: true } rather than a relaxation
of one rule, so with it on a signed-in person can register a Bot at a link-local
address.
Now mirrors the KEY_ENCRYPTION_KEY placeholder at config.ts:228: throws under
NODE_ENV=production naming the variable and what to do, warns everywhere else
and still allows it, so the local workflow is unchanged. The example line is
commented out, since copying the file is how a deployment ends up with it.
Dockerfile set the switch and NODE_ENV=production, so the all-in-one image would
have refused to start. The comment justified it as the server reaching its own
browser, but that path is checkComputerAddress, which never consults this switch
and decides on the protocol and the metadata floor alone. The switch only
governed where a Bot may browse, which on a default bridge network is the host's
LAN. Removed, and the comment now says what it actually controls.
The cloud metadata floor is unchanged and still refuses 169.254.169.254 and its
spellings with the switch on. The flag-absent default is still false, now with a
production test guarding it.
davidmckayv
left a comment
There was a problem hiding this comment.
Verified both directions on a real boot.
Refuses in production, and names the variable:
NODE_ENV=production AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
-> AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true is for local development only: it lets a Bot
reach this deployment's own network. Remove it from this deployment's environment.
With the flag off and everything else identical, it starts. In development it warns instead, which I watched the running server print on the stacked branch.
Why this is the highest-value of the three security fixes: it is on the documented happy path. Quick start says copy .env.example, and that file shipped the flag on; Deploy it runs the image, which bakes NODE_ENV=production. So a by-the-book deployment ran with the private-address floor down. In a template that is not one deployment, it is every clone that followed our own instructions.
I checked the claim in the Dockerfile comment too, since removing an ENV on a stated justification is where this could have gone wrong: checkComputerAddress takes no options and never consulted the flag, so the comment was wrong and removing it cannot break the server-to-computer path.
The cost you flagged is real and I would still merge. In the all-in-one image you can no longer register a BYO agent at a LAN address, with no opt-back-in. Bring-your-own-agent is a headline capability, so that deserves a follow-up — an explicit allowlist of hosts would serve the legitimate case without reopening the floor. But shipping "copy this file and deploy" with the guard down is worse than shipping this and adding the escape hatch deliberately.
Checks: stacked with ten other candidates on current main. Typecheck, lint, format clean; 1371 tests pass, 0 fail.
…t#185) Review of CopilotKit#198 turned up three things. NODE_ENV was compared raw while AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS goes through optional(), which trims. A trailing space out of an env file walked past the refusal while the switch beside it still counted as set. Now read through the same helper, with a test. The Upgrading entry only covered the deployment that fails to start. The one-container image set the switch and NODE_ENV=production, so it really did run with private-host browsing on: a Bot could browse a private address there and a coworker could be registered at one, and both stop. The image bakes NODE_ENV=production, so there is no override from inside it. Named in the entry rather than left to be found. README, docs/configuration.md and docs/architecture.md each describe the switch and none mentioned the refusal. The architecture bullet sits three lines under the KEY_ENCRYPTION_KEY one that already says it, which is the most visible place the parity was missing.
What this changes
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTSis now refused in production, the way the placeholder encryption key already is, and it no longer ships live in.env.example.Fixes #185. Thanks @beardthelion — the matrix is what made this quick to act on, and framing it as "forgetting is not the path that gets you there, copying the example is" is the whole argument. You offered to send a PR for whichever shape we picked; I took option 1 and the reason is below, along with something the matrix did not reach.
The gate
config.tsread the switch as a plain equality with no environment gate, and.env.example:162shipped it on, so a deployment inherited it by copying the file.It lifts more than browsing.
app.ts:646hands the same value tocreateAgentRoutes, andcomputer/target.ts:257is an earlyreturn { allowed: true }rather than a relaxation of one rule, so with it on a signed-in person can register a Bot at a link-local address.privateHostsAllowed()now mirrors theKEY_ENCRYPTION_KEYplaceholder block atconfig.ts:228-241: throw underNODE_ENV=production, warn everywhere else and still allow it. The local workflow is unchanged — the switch still works on a laptop, it just says so on the way past.The example line is commented out rather than deleted, so it stays discoverable with an explanation of what it lifts.
The image would have refused to start, and the reason it was set was not true
Dockerfile:179setENV AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=trueand:203setsENV NODE_ENV=production. Nothing unsets either and there is no compose override, so this change alone would have stopped the all-in-one image booting.The comment above it explained the switch as the server reaching its own browser:
That path is
AGENT_COMPUTER_URL→gateway.ts:248→checkComputerAddress, and that function never consults this switch.awk '/export function checkComputerAddress/,/^}/' server/src/computer/target.ts | grep -c allowPrivateHostsreturns0; it decides on the protocol list and the metadata floor alone. So the switch bought nothing for that address, and what it did buy was letting a Bot browse whatever the container's network reaches — on a default bridge, the host's LAN.The line is removed and the comment now says what the switch actually governs. That makes the image match the example: neither ships with it on.
What the image loses, and this is the part worth a decision. The switch was doing something there, just not what the comment claimed: with it on, a Bot in the all-in-one could browse a private address, and a coworker could be registered at one —
http://10.0.0.20:8000/ag-ui, say. Both stop. Because the image bakesNODE_ENV=productionatDockerfile:203, there is no way to opt back in from inside it, so anyone who needs either wants the compose setup or their own image.That is in the Upgrading entry now rather than left to be discovered. If you would rather the image kept an escape hatch, that is a design call and I would rather raise it than assume: the options are leaving the image as it was and accepting that it ships with the floor down, or giving the refusal a deliberate override that is harder to arrive at by copying a file. I have not built either.
What has not moved
The cloud metadata floor is unchanged and still refuses
169.254.169.254,metadata.google.internaland[::ffff:169.254.169.254]with the switch on —computer-target.test.ts:33-79already loops those over[false, true]and is untouched. The flag-absent default is stillfalse, and now has an explicit production test guarding it.Worth saying plainly: with the metadata floor holding, this is defence in depth rather than a live breach. The default was already right. What was wrong is that the opt-in was ungated and the two files most likely to be copied both arrived with it on.
Reference tables
README.md:205,docs/configuration.md:181anddocs/architecture.md:185each describe the switch and none mentioned the refusal. That last one sits three lines under theKEY_ENCRYPTION_KEYbullet which already says "refused withNODE_ENV=production", so it was the most visible place the parity was missing. All three now say it.Where it runs
Boundary and audit
isTargetAllowed,checkAgentEndpoint, ortarget.ts's early return. This only decides whether a deployment is allowed to ask for the floor to be lifted.Changelog
An
Upgradingentry underUnreleased, in the voice of the two refuse-to-start entries v0.0.3 already shipped. A deployment that copied the example and runsNODE_ENV=productionwill not start until the line is removed, and the entry says exactly that and how to fix it.Proof
Eleven tests added, all pure, none needing a database, each written first and confirmed failing for the right reason:
allowPrivateHosts: falseNODE_ENV=developmentandNODE_ENVunset both warn and still allow it (2)false,1,yesand""all load in production — only the exact opt-in is refused (4)NODE_ENV="production "with a trailing space still refuses —optional()trims the switch, so comparingNODE_ENVraw would let whitespace out of an env file walk past the gate while the line beside it still counted as set.env.examplefor an uncommented assignmentMutations, each applied alone and reverted:
throwremovedconsole.warnremovedNODE_ENV, not only productionreturn truereplaced withfalsereturn falseat the top of the helperNODE_ENVcompared raw instead of throughoptional().env.exampleMeasured by stashing in the same worktree:
6826e11bun run testExactly the eleven new tests; the 126 Postgres failures and the pre-existing
@langchain/coreerror are the baseline set unchanged.bunx biome lint .0 warnings,format:check,typecheckandbuildclean.One thing I could not run here: the image. There is no Docker on this machine, so the Dockerfile change is verified by reading — that
checkComputerAddressnever consults the switch, and that nothing else in the image ordocker-compose.ymlsets it. Since #117 theimagejob only builds onmainor on a PR carryingfull-ci, so this wants the label before it merges rather than merging unbuilt.Noticed while writing the tests
baseEnvironmentinserver/tests/config.test.tscarries the placeholderKEY_ENCRYPTION_KEY, so anyNODE_ENV=productiontest built on it throws on the key before reaching its own subject. I added aproductionEnvironmentfixture with a real key rather than working around it. Nothing existing broke: no test or fixture sets this variable, and the only other test touchingNODE_ENV=productioncallssingleUserEnableddirectly rather thanloadConfig.Possibly its own issue
The switch only reaches anything through
config.computer?.allowPrivateHosts, so a deployment that sets it with no computer configured boots without hitting this refusal. Harmless today, since the value reaches nothing, and it fails closed the moment a computer is added. Left alone rather than widened into this change; happy to file it if you would rather it were closed.