GHSA-m53j-2766-6jqw. A websocket subscription was authorized once,
when it opened, and that decision was trusted for the rest of the
connection's life, so a member whose access was revoked kept receiving
live file content as long as the tab stayed open.
The revocation travels over the message bus instead of closing the
socket from the mutation: the connection registry is local to one
backend instance, while the RPC that revokes access runs on whichever
instance received the request. Every instance re-verifies the
subscriptions it owns with the same `has-read-permissions?` predicates
that `:subscribe-file` and `:subscribe-team` authorize with, so a
downgrade to viewer keeps its subscription and a non-member
organization owner keeps read-only access.
Two mechanisms cover each other:
- Access-changing mutations announce after the write. delete-team-
member, leave-team, update-team-member-role, delete-file,
delete-project, move-files, move-project and delete-team announce.
delete-share-link is deliberately excluded: a share-link visitor
never passes the subscribe permission check, so there is no
subscription to revoke. Team-wide changes publish one event per
team; the watcher resolves the members itself, and both publishers
defer through `db/after-commit` so the watcher never decides from
pre-commit state.
- Every relay re-checks access every 5 minutes by default
(`subscription-revalidation-interval`), driven from the relay loop
with `alts!` over the subscription channel and a one-shot tick
channel, so a steady stream of edits cannot push the check away
forever.
Supporting changes:
- The registry holds `:connections` and `:by-profile` in one atom,
kept consistent by register/unregister in a single swap!; a profile
lookup is one map access, and a profile whose last connection
leaves is dropped from the index.
- `close-file-subscription` and `close-team-subscription` announce
the :leave-* message, close the relay channel and purge the bus
subscription, and are idempotent. Closing the channel ends the
relay loop and its re-check task.
- The permission query the re-check uses filters soft-deleted files,
projects and teams, matching its sibling queries.
- A transient database error fails open: it logs and keeps the
subscription; the next tick retries.
- The revocation watcher's params schema declared the nitrate client
as a bare keyword inside [:map ...], which malli resolved as a
registry lookup and which took the whole system build down under
enable-backend-asserts. It is now an explicit pair, optional
because init-key yields nil with the admin-console flag off.
The tests reproduce the leak against the real message bus, including
the negative control for the interval re-check. The written records
live in mem:backend/websocket-subscriptions.
Closes#12155
AI-assisted-by: space-bunny-free
AI-assisted-by: muse-spark-1.3-contributor