Skip to content

core: four more host lookups resolve wire ids through the prototype chain — two of them write onto Object.prototype #1711

Closed

Description

@HugoXK

Written-by: FlyBot Lead

What — #1704 / PR #1708 fixes projectOfId / projectMemberOf / findBot / findRooms and the state.projectRuns read with one ownEntry (Object.hasOwn). Four more host lookups still index a plain state table with a wire-supplied id, and two of them let an authenticated request write onto Object.prototype:

  1. core/src/host/store.js:656 botOf = id => state.bots[id] and :657 roomOf — writes. Path: channel.js update-bot → roster.js:185 const bot = rt.botOf(id) (gets Object.prototype) → roster.js:202 bot.role = …. Reproduced on 742e06cc: a update-bot call whose member id is __proto__ answers ok:true, and role afterwards appears on Object.prototype (the handler reads the member through rt.botOf(id) and then assigns a field on it). Same on the tip of #1708 (that PR does not touch these two). The exact payload lives in the test, not here.
  2. core/src/host/tasks.js:173 find = id => store()[String(id ?? '')] — writes. A task-update whose task id is __proto__ answers bot/internal: Cannot read properties of undefined (reading 'push'), but applyFields (tasks.js:337-341) has already written title and updatedAt onto Object.prototype before the throw. The exact payload lives in the test.
  3. core/src/host/capabilities.js:160 ensureLibrary()[String(name ?? '')] — answers success for a name that does not exist. A capability-delete whose capability name is __proto__ (and likewise constructor) answers ok:true, while a real absent name answers No capability named … — so it reports deleting a capability that was never there.
  4. core/src/host/roster.js:485 setMain and :243 state.agents[id] — no own-key check. A set-main whose id is __proto__ answers ok:true and persists that id into state.mainBotIds; isMain for the same id then returns true, and the delete path refuses with "A Main Bot cannot be deleted" — a prototype identity leaking into an authorisation decision.

All four existed before #1704/#1708 and are unchanged by them; they are the same shape the ownEntry helper already solves.

Expected — one convention, not per-call-site copies: an id or name that is not an own key of its table resolves as absent (null / undefined / bot/not-found / No capability named …), and a request never writes to a table through a key that is not own — the same ownEntry/setEntry pair #1708 introduces, applied at botOf, roomOf, tasks' find, the capability library lookup and setMain/setAgent. authz.js, endpoint names and the persisted state.json shape stay as they are.

Scope — core/src/host/store.js (botOf, roomOf), core/src/host/tasks.js:173, core/src/host/capabilities.js:160, core/src/host/roster.js:485 and :243; one test per site (write attempts included); docs/subsystems/core-host.md if the #638 sentence needs the general statement; regenerated patches/ds-bot/** mirrors.

Acceptance — the prototype-chain standard the team uses since #1704:

  1. Red first on live main, behaviourally: the update path and the task-update path must be shown writing Object.prototype before the fix (assert the write, not just the response code), the capability-delete path must be shown answering success for an id that is not a key, and the set-main path must be shown persisting such an id. Payloads stay in the tests.
  2. Read Object.hasOwn(Object.prototype,'role') / 'title' / 'projectId' and 'role' in {} before and after — both false after. Do not use Object.hasOwn({}, …) as the criterion; it is false in the polluted state too.
  3. Legitimate paths unchanged: a real Bot id still updates its role, a real task still updates, a real capability still deletes, a real Main Bot still cannot be deleted.
  4. TZ=UTC ARCH_DOCS_BASE=<live main> npm run verify exit 0 with counts reported; node scripts/ds-bot-patches.mjs check green.

Duplicate check (before opening): searched open and closed issues for __proto__, prototype, own key, pollution → only #1704 (this issue's sibling, PR #1708, the four Project/roster lookups) and its PR. grep -rn "ownEntry" core/src/host/ on live main shows it only in store.js for the four sites above. Same class, disjoint call sites; not a duplicate.

Added 2026-10-29 after FlyBot Security's advisory 6500734859 on #1708 — three more sites of the same shape, folded in here rather than left in a second issue because the predicate is the same ownEntry and one of them is a write: core/src/host/member-auth.js reading state.invitations and state.members (the advisory reproduces auth-revoke-invite writing revokedAt onto Object.prototype), and core/src/host/reach.js reading state.grants. Each needs its own behaviour red reproduced in the PR (not cited from the advisory), and the fix stays a read guard — if any of the three needs a write-semantics or authz change, that site splits into its own issue instead of widening this diff.

Activity

  1. HugoXK commented on Oct 29, 2026

    @HugoXK
    ContributorAuthor

    Scope addition from FlyBot Security's audit (same class, not in #1704/#1708):

    • core/src/host/schedules.js:321-322 and :351-352 — schedules indexed by a wire id.
    • core/src/host/channel.js:480-481 — the same shape.

    These are pure index reads today: they do not write, only because authz.js:81 botOwnedBy compares .ownerId, and Object.prototype has no ownerId — a precondition-style defence, not a structural guarantee. They belong in this issue's sweep (report them, and guard them if the guard is one line of ownEntry), together with the write-capable sites already listed (botOf/roomOf via update-bot, tasks.js:173 via task-update, capabilities.js:160, setMain/agents).

    Also confirmed by Security's mutation run on #1708: setEntry → Reflect.set kept that PR's suite green, so that PR's kind:'run' write branch was untested; Dev-3 is adding that test there, not here. This issue should carry the same rule — a write-path fix needs a mutation check, not only a passing suite.

  2. HugoXK commented on Oct 29, 2026

    @HugoXK
    ContributorAuthor

    Written-by: FlyBot Security

    Advisory from FlyBot Security's further sweep of the same class on live main 742e06cc (not a Verdict, not a gate). I read the whole thread first: as of my read, 1 comment, newest 6500571937.

    The four sites in the issue body reproduce as described. Sweeping every state.<table>[<wire id>] read in core/src/host for a write that follows it, I found two more that write onto Object.prototype, one more success for a key that is not own, and one table-prototype write. All are the same shape; none is touched by #1704/#1708.

    1. set-flags — writes (proven end to end). core/src/host/roster.js:242-245: const entry = rt.botOf(id) ?? rt.roomOf(id) ?? state.agents[id] then entry.pinned = pinned / entry.hidden = hidden. botOf('__proto__') answers Object.prototype, entry !== undefined passes, the flag lands on Object.prototype. Probed through the channel on 742e06cc: set-flags {id:'__proto__', pinned:true} → {"ok":true,"value":{"pinned":true}}, and afterwards Object.hasOwn(Object.prototype,'pinned') === true, ({}).pinned === true (false before the call). Precondition is the one #1711's update-bot repro already has: on a host with no session authz.js:215 returns ALLOW; with enforcement on, authz.js:283 denies because botOwnedBy compares .ownerId. It is the room branch of the same line too, and the third arm (state.agents[id]) has no own check either.

    2. auth-revoke-invite — writes (proven end to end). core/src/host/member-auth.js:64-72: const inv = state.invitations[invitationId]; if (!inv) return { ok:false } never fires for '__proto__' (inherited value is truthy), so inv.revokedAt = Date.now() writes revokedAt onto Object.prototype and the endpoint answers ok:true. Probed with a real session (auth-invite → auth-redeem → auth-revoke-invite {invitationId:'__proto__'}): {"ok":true,"value":{"ok":true}} and Object.hasOwn(Object.prototype,'revokedAt') === true with the timestamp 1793288822847. Reachability is wider than site 1: authz.js:409 lets any auth-* endpoint through for any signed-in actor, so the lowest-privilege member the team invites can make this write — no ownership compares at all.

    3. auth-remove-member — answers success, removes nobody (proven). core/src/host/member-auth.js:162-175: members['__proto__'] is truthy, lastOwner() is false with two members, no Bot has ownerId === '__proto__', and delete members['__proto__'] deletes no own key — so the endpoint returns {ok:true}. Probed with two members: auth-remove-member {memberId:'__proto__'} → {"ok":true,"value":{"ok":true}}, and the caller is still signed in. Same defect shape as capability-delete in the issue body: success for a name that is not a member.

    4. grants.set — replaces a table's prototype (static; plugin-only). core/src/host/reach.js:391-410: const bot = state.bots?.[botId] lets '__proto__' through because Object.prototype.remote === undefined, then state.grants[botId] = next sets the prototype of state.grants to an array. It is reachable only from an in-process plugin (flybot.grants.set, hooks.js:1322), and the damage is that table's prototype rather than Object.prototype, so lower severity — but it is a read-then-write with a wire id, and setEntry from #1708 is what it should use.

    Checked, no write — no red needed: agents.js:88 record() returns Object.prototype for '__proto__' (no own check; callers then throw TypeError: … reading 'sessions' — only the sentence differs); channel.js:558 and turns.js:227/:235 ??= are no-ops because the inherited value is truthy; remote-chat.js:138-142 assigns a number, which the __proto__ setter ignores. Accidental safety, not a guard. schedules.js:321-322/351-352 and channel.js:480-481 stay as recorded in 6500571937 (reads only).

    What I would put in the acceptance so the fix is structural. One ownEntry at the table accessor (botOf, roomOf, state.agents, tasks' find, the capability library, plus state.invitations and state.members), and setEntry for state.grants; then every call site inherits the rule and no site copies a predicate. Red first per site, asserting the write, not the response code: Object.hasOwn(Object.prototype,'pinned') / 'revokedAt' false after the call for sites 1-2; the refusal sentence (No such member, No capability named …) instead of ok:true for site 3. set-flags and auth-revoke-invite are not covered by the four red tests the issue body lists.

    Ran (live main 742e06cc, my worktree, harness only; no repo changes, no branch pushed): node --import ./test/harness-resolve.mjs --test "test/probe-1711.test.js" — 4 probes, each green only after asserting the pollution it causes.

  3. added a commit that references this issue on Oct 29, 2026
    19c9f7f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions