Skip to content

fix(core): stop gating hub-relayed room-domain verbs behind bare-device trust - #195

Merged
Mearman merged 3 commits into
mainfrom
fix/gateway-gate-room-domain-verbs
Sep 18, 2026
Merged

Mearman merged 3 commits into
mainfrom
fix/gateway-gate-room-domain-verbs

Conversation

@Mearman

@Mearman Mearman commented Sep 18, 2026

Copy link
Copy Markdown
Member

Closes #192

Extracts HubSession's per-request dispatch decision into an exported dispatchHubRequest function and removes the coarse isTrusted allowlist check for a real room-domain verb (room.send, room.join, room.notify, ...). That traffic already carries its own independent room:member capability token, verified regardless of transport path and principal-aware since #187, so stacking the bare-device gateway allowlist in front of it was redundant, and for room.join specifically it prevented an untrusted device from ever reaching the deliberate human-approval step.

The legacy FRAME_VERB path keeps its isTrusted gate, since it carries no independent per-message security of its own (that's the actual spoofed-delivery risk #169 caught). A request with no fromDevice at all is now refused before either path, rather than falling through to a room-verb handler that would call deviceIdFromHex on a non-hex placeholder and throw.

Also widens the hub gossip directory-merge filter to accept a device that is itself a trusted user principal (via GatewayTrust.isTrustedPrincipal), not only one on the bare-device allowlist.

Verification against the issue's own reasoning

The issue's decided fix assumed room-domain verb dispatch is safe to leave ungated at the gateway layer because it has its own independent capability-token verification. Reading the actual downstream code (room-protocol.ts) confirmed this is true for an identified sender, but also surfaced a real gap the issue's own text didn't call out: every room-verb handler calls deviceIdFromHex(handle.id) unconditionally to build the token's expected bearer (or, for room.join, the minted grant's own bearer). Under the old code, an unidentified sender (no fromDevice on the relayed request) always fell into the coarse isTrusted rejection first, so this was never reachable. Removing that gate for room-domain verbs would have let an unidentified sender's request reach a handler that throws on deviceIdFromHex("hub-peer"), an unhandled exception that kills the whole session's drain loop rather than answering with an ordinary error. Fixed by refusing any request with no fromDevice at all before either dispatch path, restoring the intended fail-fast behaviour.

Test plan

  • Unit tests for dispatchHubRequest covering every branch (FRAME_VERB trusted/untrusted, room-domain trusted/untrusted, missing fromDevice, on-behalf-of forwarding, state_sync/state_update filtering)
  • Full existing test suite passes
  • End-to-end hub integration tests proving a real room.join from an untrusted-gateway device reaches the human-approval flow (both accept and reject outcomes) instead of being rejected outright, confirmed RED against the pre-fix dispatch logic before restoring the fix
  • End-to-end hub integration test proving gossip directory-merge now accepts a principal-only trust, not only the bare-device allowlist

…ce trust

Extract HubSession's per-request dispatch decision into an exported
dispatchHubRequest function and remove the coarse isTrusted allowlist
check for a real room-domain verb (room.send, room.join, room.notify,
...): that traffic is already independently gated by its own
room:member capability token, verified regardless of transport path
and principal-aware since the #187 delegation work, so stacking the
bare-device gate in front of it was redundant and, for room.join
specifically, prevented an untrusted device from ever reaching the
deliberate human-approval step.

The legacy FRAME_VERB path keeps its isTrusted gate, since it carries
no independent per-message security of its own. A request with no
fromDevice at all is now refused before either path: dispatching it
to a room verb handler would otherwise call deviceIdFromHex on a
non-hex placeholder and throw, killing the whole session's drain loop
instead of answering unauthorized.

Widen the hub gossip directory-merge filter to accept a device that
is itself a trusted user principal, not only one on the bare-device
allowlist, so a principal already trusted via addPrincipal surfaces
in gossip without a separate per-device bootstrap.
…usted

Add wireTestTransportWithHub alongside the existing wireTestTransport,
returning the constructed WireMeshTransport instance itself so a test
can reach transport.hub directly (connectHub, hub.peers(), isConnected)
on top of a real MeshStore's own room verb handlers, since MeshStore
exposes no public hub-connect wrapper outside CoordinatorGateway's own
becomeCoordinator flow.

Add an end-to-end test over a real relay hub proving a room.join from
a device the owner's own GatewayTrust never trusts still reaches the
deliberate human-approval flow (listPendingRoomJoins, then accept or
reject) instead of being rejected outright by the coarse gateway
allowlist, and that hubPeersKnown stays scoped to gateway-trusted
devices even after a successful admission. Confirmed this fails
against the pre-fix dispatch logic before restoring it.
Add an end-to-end hub test where the discovering side trusts the
remote device only as a user principal (addPrincipal), never on the
bare-device allowlist, and confirms connect()'s own directory-merge
filter still surfaces it in hub.peers() while isTrusted itself stays
false for that device -- the two allowlists remain genuinely separate,
only the directory-merge filter now checks both.
@Mearman
Mearman force-pushed the fix/gateway-gate-room-domain-verbs branch from 9924c25 to 995e1c3 Compare September 18, 2026 09:09
@Mearman
Mearman marked this pull request as ready for review September 18, 2026 09:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-18T09:24:32.869874Z 995e1c3 Draft marked ready

Security findings

Advisory findings (1)

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit bbbdaf6 into main Sep 18, 2026
11 of 12 checks passed
@Mearman
Mearman deleted the fix/gateway-gate-room-domain-verbs branch September 18, 2026 09:15
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 3.17.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 995e1c3e2f

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/core/hub-session.ts
return;
}
}
await deps.handleRoomRequest(request, handle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

P1 Badge Security: Bind auto-approved DM joins to the requesting device

When A previously initiated a DM with B, any untrusted hub client C can now send room.join for A+B: this dispatch reaches handleRoomJoin without isTrusted, which checks only that A is a participant and auto-approves solely because the path is in dmRequestsInitiatedByMe. admitRoomJoin then signs a room:member token for C. C can use it for room.notify; a dm event has no room field, so the scope check accepts an event claiming from: B and delivers a spoofed DM to A. Require handle.id to equal the other DM participant before auto-approval or grant minting.

Useful? React with 👍 / 👎.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gateway enforcement never consumes principal-keyed trust

1 participant