Keep a Bot id from naming a directory it should not - #30
Conversation
078bc98 to
ed33889
Compare
davidmckayv
left a comment
There was a problem hiding this comment.
Right answer, and defence in depth rather than one check: refused at the request boundary because the header is this process's input, and again where the path is built so there is one checked way to name a profile directory.
I reproduced the attack on main before reviewing this, so the fix is measured against something real rather than described. Created /tmp/canary/marker.txt inside the computer container, sent x-openbot-bot-id: ../../tmp/canary to POST /computers/reset with the computer token, got {"reset":true} back and the directory was gone: /profiles/../../tmp/canary resolved to /tmp/canary and was removed recursively, as root.
Rebuilt the image on this branch and ran the same thing. 400, That is not a usable bot id., and the file is still there. ../workspace, sales/../../etc, .. and a/b are all refused, and general-assistant, risk_analyst and a uuid-shaped agent id all still answer 200.
Matching supervisor/src/names.ts is the right call: the same class of input already had a line held for container and volume names, and a second, subtly different set of rules would be a worse answer than reusing the one that works. Validating COMPUTER_BOT_ID at boot rather than per request is right too, since a refused default would answer 400 to everything and read as a broken computer rather than a misconfigured one.
Keeping the check out of index.ts so it can be tested without Playwright is the same split authorisation.ts makes, and it is what lets the 27 cases exist at all.
139 tests pass in agent-computer. Rebased onto main, typecheck and format clean.
One interaction to flag: I have a branch for #88 that also changes profiles.ts, bounding how many browsers one computer holds. Whichever of the two lands second needs a small rebase; I will take that on my side.
A Bot id arrives as a request header, and the API server forwards whatever segment a caller put in the URL. `profiles.directoryFor` was `join(root, botId)`, which normalizes `..` away, so `../workspace` resolved outside the profiles root and `reset` deleted whatever was there with `rm -rf`, as root. Reached from the product, not only from a leaked computer token: every acting route under `/api/computers/:botId` requires a signed-in user and nothing more, and the server attaches its own token when it forwards the id. So `POST /api/computers/..%2Fworkspace/computers/reset` from an ordinary account deleted the durable workspace volume. A bare `..` is normalized away by the router; the encoded form is not. The rules are the ones `supervisor/src/names.ts` already applies to container and volume names, for the same reason and in the same words: letters, digits, hyphen and underscore, starting with a letter or digit. No separators, so an id cannot escape into another path segment; no dots, which invite `..` reasoning. The check sits in its own file rather than in `index.ts`, which imports Playwright at module scope, so it can be tested without a browser, the same split `authorisation.ts` already makes. Refused at the computer's own request boundary as well as where the path is built. The header is this process's input, so it holds the line itself rather than trusting the caller to have checked. `/health` stays exempt, as it is from the token, because it names no Bot and an orchestrator's probe should not fail on a header it never meant to send. `COMPUTER_BOT_ID` is validated at boot rather than per request. It is the id every unheadered call falls back to, so a value these rules refuse would answer 400 to everything and read as a broken computer.
ed33889 to
f66fdaa
Compare
Closes #29.
profiles.directoryForwasjoin(root, botId), which normalizes..away, so a Bot id of../workspaceresolved outside the profiles root andresetdeleted whatever was there withrm -rf, as root. The id comes from the URL, and every acting route under/api/computers/:botIdrequires a signed-in user and nothing more, with the server attaching its own computer token when it forwards the id.POST /api/computers/..%2Fworkspace/computers/resetfrom an ordinary account was enough.What it does
Holds a Bot id to the rules
supervisor/src/names.tsalready applies to container and volume names, for the same reason and in the same words: letters, digits, hyphen and underscore, starting with a letter or digit. No separators, so an id cannot escape into another path segment; no dots, which invite..reasoning.Enforced in two places. Where the path is built, so there is one checked way to name a profile directory, and at the computer's own request boundary, because the header is this process's input and it should not depend on the caller having checked.
/healthstays exempt, as it already is from the token, since it names no Bot and an orchestrator's probe should not fail on a header it never meant to send.The check lives in its own file rather than in
index.ts, which imports Playwright at module scope, so it can be tested without a browser. That is the splitauthorisation.tsalready makes, and for the same stated reason.COMPUTER_BOT_IDis validated at boot rather than per request: it is the id every unheadered call falls back to, so a value these rules refuse would answer 400 to everything and read as a broken computer.names.tsvalidates its namespace the same way.Verification
27 cases in
agent-computer/tests/bot-id.test.ts, failing before the change and passing after: the traversal forms, and the ordinary ids that have to keep working (sales,support-1,risk_analyst,shared, a uuid-shaped id, a single character).The behaviour itself was exercised against the real
createProfileson a sandboxed tree, before and after. Before,reset("../workspace"),reset("../etc"),reset("../../above")andreset("sales/../../etc")each deleted a directory outside the profiles root. After, each is refused and every file is still there, whilereset("sales")still deletes exactly that profile.Not covered by a test: the guard in
index.tsitself. That file callsserve()at module scope, so no test can import it, which is the same reason its routes have no tests today. The logic it calls is what the 27 cases cover.agent-computer: 118 pass, 0 fail.server: same failure set asmain(integration tests that want a Postgres). Typecheck and biome clean.Not in scope
No acting route resolves
:botIdagainst a row inbots, so any signed-in user can act on any Bot's computer even with a well-formed id. That is a separate question from path confinement and is noted at the end of #29.