profiles.directoryFor in agent-computer/src/profiles.ts:118 is join(root, botId) with no confinement, and reset() runs rm(directoryFor(botId), { recursive: true, force: true }) as root. join normalizes .., so an id of ../workspace resolves outside the profiles root.
The id is not an internal value. It comes from the URL, and the shared (non-supervisor) deployment carries it end to end:
server/src/computer/routes.ts:215 takes :botId behind requireUser and nothing else, no validation.
server/src/computer/gateway.ts:433 calls as(botId).resetComputer().
server/src/computer/client.ts:187 sends it verbatim as x-openbot-bot-id, and attaches the server's own x-openbot-computer-token. The caller never needs that token.
agent-computer/src/index.ts:137 botIdOf trims it; :636 routes /computers/reset to profiles.reset(botId).
So POST /api/computers/..%2Fworkspace/computers/reset from an account holding only the base user role deletes the durable workspace volume. A bare .. is normalized away by the router; the percent-encoded form is not.
Verified at main 06a1a84, both halves separately.
Driving the real Hono routes with a role-user actor and a recording fetch, so the front half is observed rather than argued:
| URL segment |
HTTP |
on the wire as x-openbot-bot-id |
sales |
200 |
sales |
..%2Fworkspace |
200 |
../workspace |
..%2F..%2Fetc |
200 |
../../etc |
Then the real createProfiles against a sandboxed tree. stop() returns early when no browser is running, so reset reaches the rm without Chromium:
reset(botId) |
deletes |
sales |
/profiles/sales, correctly confined |
support |
/profiles/support, correctly confined |
../workspace |
the workspace volume |
../etc |
a sibling of the profiles root |
../../above |
two levels up |
sales/../../etc |
a sibling of the profiles root |
/etc |
/profiles/etc, confined, since join treats it as relative |
Deletion is the sharp end, but page() passes the same unconfined path to launchPersistentContext, so Chromium also writes a user-data directory wherever the caller points.
Two things bound it. The supervisor deployment is unaffected: supervisor/src/names.ts holds ids to a strict allow-list, and running these same inputs through namesFor refuses every one of them. And agent-computer/src/workspace.ts confines file paths in three layers, so the workspace tools were never the way in. The profile path is the one place an id becomes a path with no check at all, and there is no test file for profiles.ts.
A PR follows that applies the rules names.ts already uses, at the computer's own request boundary and where the path is built.
Worth noting separately, since it is out of scope for that PR: no acting route resolves :botId against a row in bots, so any signed-in user can act on any Bot's computer even with a well-formed id.
profiles.directoryForinagent-computer/src/profiles.ts:118isjoin(root, botId)with no confinement, andreset()runsrm(directoryFor(botId), { recursive: true, force: true })as root.joinnormalizes.., so an id of../workspaceresolves outside the profiles root.The id is not an internal value. It comes from the URL, and the shared (non-supervisor) deployment carries it end to end:
server/src/computer/routes.ts:215takes:botIdbehindrequireUserand nothing else, no validation.server/src/computer/gateway.ts:433callsas(botId).resetComputer().server/src/computer/client.ts:187sends it verbatim asx-openbot-bot-id, and attaches the server's ownx-openbot-computer-token. The caller never needs that token.agent-computer/src/index.ts:137botIdOftrims it;:636routes/computers/resettoprofiles.reset(botId).So
POST /api/computers/..%2Fworkspace/computers/resetfrom an account holding only the baseuserrole deletes the durable workspace volume. A bare..is normalized away by the router; the percent-encoded form is not.Verified at
main06a1a84, both halves separately.Driving the real Hono routes with a role-
useractor and a recording fetch, so the front half is observed rather than argued:x-openbot-bot-idsalessales..%2Fworkspace../workspace..%2F..%2Fetc../../etcThen the real
createProfilesagainst a sandboxed tree.stop()returns early when no browser is running, soresetreaches thermwithout Chromium:reset(botId)sales/profiles/sales, correctly confinedsupport/profiles/support, correctly confined../workspace../etc../../abovesales/../../etc/etc/profiles/etc, confined, sincejointreats it as relativeDeletion is the sharp end, but
page()passes the same unconfined path tolaunchPersistentContext, so Chromium also writes a user-data directory wherever the caller points.Two things bound it. The supervisor deployment is unaffected:
supervisor/src/names.tsholds ids to a strict allow-list, and running these same inputs throughnamesForrefuses every one of them. Andagent-computer/src/workspace.tsconfines file paths in three layers, so the workspace tools were never the way in. The profile path is the one place an id becomes a path with no check at all, and there is no test file forprofiles.ts.A PR follows that applies the rules
names.tsalready uses, at the computer's own request boundary and where the path is built.Worth noting separately, since it is out of scope for that PR: 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.