Skip to content

Inbound room-share envelopes let the payload decide the inviter and the author, not the transport #909

Closed

Description

@Birfy

Priority: P1 (security) — found while answering "is the payload-first inviter in #907 new, or already on main?". Answer: already on main (093ff4d4), and it is not only about the roster.

Read from source at 093ff4d4 on this machine (no checkout here, so nothing below was executed; every claim is a line you can open).

The shape: the envelope decides who the inviter is

applyInboundRoomShare (core/src/host/room-membership.js:1802) takes the inviter's identity from the payload first and the transport only as a fallback:

:1808  const fromHandle = data.inviterHandle || event.fromHandle
:1809  const fromHome   = data.inviterHome  || event.fromHome

The comment above it says why that was done (#873: so a room reads "Shared room from Alice" rather than after the sender's Bot). The problem is that the same two values are then used as the gate, not just for display.

Three consequences, each on its own line

1. A sender who is not the inviter lands in your existing mirror room. inboundOf(data.roomId, fromHome, fromHandle) (:1830) looks a room up by remoteRoomId plus the claimed inviter. Anyone who can deliver an envelope to your Bot and names your room's roomId and inviter matches your room, and the line is appended to it (:1922-1935). There is no comparison between event.fromHome/event.fromHandle and room.sharedFrom on this path.

2. The same envelope rewrites the room's recorded identity. Once matched, room.sharedFrom is overwritten from the payload — inviterName, inviterHome, inviterHandle, invitedHandle, remoteRoomId (:1909-1916). So a mirror room can be made to say it came from someone else, and later inboundOf calls for the real inviter no longer find it (the transport fallback at :1831-1837 only runs when no room matched at all).

3. A bare line envelope projects a whole room. :1869-1885 calls recordInboundSharedRoom for any op that reached this point, including line. The guard at :1828-1829 ("a line for a room of mine from someone it does not seat is dropped") only covers a room that already exists locally; with an unknown roomId the room is created, named from the payload, with the payload's inviter and the sender's line already in it.

Plus the authorship claim, which is the same "payload is proof" mistake:

:1926  authorId: data.authorId || fromHandle
:1927  authorKind: data.authorKind || 'human'
:1930  botId: data.authorId || fromHandle

A line can therefore be displayed as written by any handle, and authorKind can make a person look like a Bot or the reverse.

What is already right, and must stay right

These are the reason this is a fixable gate rather than a redesign — the transport identity is present and already trusted in the same file:

  • ownSeatOf matches a seat by the transport's fromHandle (:1562-1564), and the home check uses event.fromHome (:1566).
  • The ticket claim check uses event.fromHandle (:1846-1848).

So the rule the fix should apply everywhere on this path: event.* decides, data.* is displayed.

Scope

  1. :1808-1809: keep the payload fields for display, but gate on the transport. A room is matched (:1830) and updated (:1909-1916) only when event.fromHome/event.fromHandle match what the room recorded; a mismatch is not a fallback, it is a refusal.
  2. :1869: a room is created only by op: 'join' (or the ask/invite answers that already return earlier). A line envelope for an unknown roomId creates nothing. The same rule applies to any op a later fix adds — #904 introduces roster and a re-sent join for the notice: those may only update a room that already exists, never project one. Write the gate on the room's existence, not on a list of ops that happen to exist today.
  3. :1926-1927, :1930: an author handle that cannot be traced to the transport sender — or, after #904, to an entry verified for this room — is shown as unverified or as the sender, never as that handle. authorKind follows the same rule. Do not drop the line either: a forged envelope must not become a way to make messages disappear (#904 blocking point 2).
  4. Keep #873's visible outcome: the room still reads "Shared room from Alice", because display still prefers the payload.

Reproduction (write this test first, red on 093ff4d4)

core/test/remote-room.test.js already builds fake transport events for applyInboundRoomShare. Extend it with an event whose transport identity is @mallory.home / @mallory.bot and whose text is encodeRoomShare({...}) claiming inviterHome: '@alice.home', inviterHandle: '@alice':

  • for op: 'join' naming an existing mirror's roomId: the room's sharedFrom.inviterHandle must still be @alice after the call, and the participant count must not change;
  • for op: 'line' naming a roomId that exists only on the sender's side: no room may appear;
  • for either: the appended line's authorId must not be the payload's authorId when that handle is not the transport sender.

Three assertions, three separate cases; each is expected to fail on 093ff4d4 for its own reason.

Who can exploit this, precisely

Not "anyone on the internet": the envelope has to be delivered to your Bot through Bot Social, so the sender needs an existing delivery path — a friend's Bot, or a seat of a room you share. That is what makes it real rather than theoretical: a guest of one of your shared rooms already knows that room's roomId and inviter handle from the envelope they received, and any friend of yours can deliver to your Bot.

Ownership against #904 / #907 — who fixes which line

Both PRs touch the same file, so the split has to be by line, not by intent:

  • This issue owns: :1808-1809 (the transport gate), :1830 (the room match), :1869 (create only for a join), :1909-1916 (no payload overwrite of sharedFrom).
  • #904 / #907 owns: the roster and notice payload itself, and the mapping at :1926-1930 from a claimed author to a roster entry verified for this room.
  • The one shared line is :1926. Whichever lands second must not rewrite the other's outcome; both must end at this single rule, so the file converges instead of holding two author deciders:

A line's author is the transport sender (event.fromHandle, event.fromHome) unless it can be mapped onto an entry already verified for this room; anything else is shown against the sender and marked unverified. The envelope's authorId/authorKind never selects the author — authorKind cannot turn a person into a Bot or the reverse.

Relation to #904

#904 covers the roster path and its blocking point 1 already requires a verified author for lines; this issue is the gate that exists today on main, independent of the roster. Both touch :1808 and :1926, so whichever PR lands first, the other rebases — but they must not both fix :1926 in one commit without saying which issue owns it.

Related

#904 — the roster and the author claim on the guest path; #873 — why the payload was preferred for display; #884 / #890 — the seats whose transport checks are already correct (:1564, :1846-1848).

Activity

  1. added
    bugSomething isn't working
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:socialFriends, invites, grants, Bot Social
    area:securityAuth, grants, approvals, secrets
    on Oct 24, 2026
  2. self-assigned this
    on Oct 24, 2026
  3. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
  4. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed on branch fix/909-inbound-share-transport-identity — PR #910 (Fixes #909, Refs #904), built on current main cbad5e58.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a templateagent-workingAn agent has claimed this issue (the claim comment names the branch)area:securityAuth, grants, approvals, secretsarea:socialFriends, invites, grants, Bot SocialbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions