fix(security): authorize kind:9000 role changes in both directions#3017
Open
tlongwell-block wants to merge 3 commits into
Open
fix(security): authorize kind:9000 role changes in both directions#3017tlongwell-block wants to merge 3 commits into
tlongwell-block wants to merge 3 commits into
Conversation
A NIP-29 PUT_USER (kind:9000) event carries a `role` tag that the relay
applied to the target member. Authorization only fired when the *new* role
`is_elevated()`, and the whole actor check was nested under
`visibility == "private"`. Demotion was therefore unauthorized by anyone:
any authenticated user could publish kind:9000 with `role=member` against a
channel owner and strip their role. Because re-granting an elevated role
requires an existing owner/admin, demoting the last owner destroyed channel
governance irreversibly, and `add_member` had no last-owner guard even
though its sibling `remove_member` did.
Make the DB layer the authority. `add_member` now gates *any* role change on
an active member behind an elevated actor, in both directions, on every
visibility, so direct callers that bypass the relay validator are covered
too. Same-role re-adds stay idempotent, which the kind:9021 self-join, the
private ephemeral-audio auto-add and the workflow-bot paths rely on. A
soft-removed row's role is treated as history rather than live authority, so
a kicked owner cannot self-rejoin and resurrect ownership. Last-owner
protection mirrors `remove_member`.
Two further defects found while fixing the reported one:
- An absent `role` tag defaulted to Member, so a bare self-targeted PUT_USER
silently demoted an owner. Absent now means "no role change requested".
- `add_member` and `remove_member` each read an owner count and then write a
different row than the one they counted, so READ COMMITTED permitted two
concurrent demotions to each observe two owners, each pass, and together
leave zero. Both now take a per-channel `pg_advisory_xact_lock` as the
transaction's first statement, ahead of every mutable authorization read.
This race predates this change and exists on main today.
No migration, no schema change, no new public API.
Verification: the authorization gap is verified live against a from-source
relay with a clean-main control -- the three new e2e regressions fail on
main printing the exploit (`victim role before = Some("owner"), after =
Some("member")`) and pass on this change, with no other result delta. The
two concurrency invariants are verified by deterministic in-process tests,
each with a watched failure, and are outside e2e's reach by construction.
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Brings the branch up to a31fc4d. No upstream commit touches any of the three files this branch changes (crates/buzz-db/src/channel.rs, crates/buzz-relay/src/handlers/side_effects.rs, crates/buzz-test-client/tests/e2e_relay.rs), and no migration changed -- latest is still 0024_event_ttl_refresh_shared_lock.sql. Conflict-free. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> * origin/main: fix(desktop): remove bundled libsystemd from AppImage (#2353) docs: document required DCO sign-off and add commit-msg sign-off hook (#2993) fix(desktop): make agent definition authoritative for model/provider/prompt (#1968) chore(desktop): delete dead persona catalog UI cluster (#2886) fix(desktop): surface install failures hidden by curl-pipe exit codes (#2892) fix(mobile): validate invite relay destinations (#2986) Refactor managed-agent runtime into cohesive modules (#2974) Refine mobile settings and themes (#2844) Fix formatting in README.md diagram (#2284) fix(desktop): make Linux AppImage GStreamer work on non-Debian distros (#2176) refactor(desktop): remove Agent directory section from Agents page (#2290) fix(desktop): enable arboard Wayland backend so Linux copies reach the Wayland clipboard (#2904) fix(desktop): supervise and re-arm relay-mesh runtime (#2823) fix(agents): run live Databricks discovery instead of the fallback list (#2890) fix(desktop): retire prepend mode on every reader wheel (#2913) fix(desktop): consolidate prepend scroll correction (#2855) docs(buzz-acp): correct agent key generation instructions (#2875)
Mutation testing the kind:9000 authorization fix — stubbing each guard
individually and re-running the suite — found three of eight guards were
not actually covered. Each survived being disabled with every test green.
The cause is the same in all three cases: the existing tests assert
resulting STATE ("the role did not change"). The DB guards in add_member
enforce that state, so they mask every layer above them. A stubbed relay
guard makes the relay answer accepted:true and log "Side effect failed:
access denied: ...", while the DB still refuses the write and the state
assertion passes. The entire relay validator could be deleted without a
single test noticing.
- unprivileged_member_cannot_demote_a_co_owner (buzz-db)
Isolates the DB actor-authorization guard from the last-owner guard.
repro_unprivileged_member_can_demote_owner demotes the SOLE owner, so
the last-owner guard alone rejects it. Two owners here, leaving the
actor check as the only thing that can.
- test_nip29_relay_rejects_role_change_by_unprivileged_actor
- test_nip29_relay_rejects_last_owner_self_demotion
Isolate the two relay-side guards. These assert accepted == false --
the one observable only the validator controls -- rather than state.
The first uses two owners so the last-owner guard cannot fire; the
second uses an elevated actor (sole owner self-demoting) so the
actor check cannot fire.
Each verified in both directions: green against the real fix, and failing
with its intended message when its guard alone is stubbed.
Tests only; no production code changes.
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
NIP-29
kind:9000(PUT_USER) role changes were only authorized when the new role was elevated. Demotions were unauthorized, so any authenticated user could strip a channel owner tomemberwith a single event — and the demotion was unrecoverable, since the ex-owner then lacked the privilege to restore themselves.Reported by @tyler in
#buzz-security. Verified true, plus two adjacent defects the report flagged and one it did not.The defects
visibility == "private".add_memberhad no last-owner guard whileremove_memberdid — so a channel could be left with zero owners.roletag defaulted toMember, so a bare self-targeted PUT_USER silently demoted the sender. No attacker required.The fix
crates/buzz-db/src/channel.rs— the authority, because it also covers the desktop/admin callers that bypass the relay validator:kind:9021join paths depend on this).add_member, mirroringremove_member.removed_at IS NULL). A soft-removed row's role is history, not live authority — otherwise soft-deleted ownership becomes a resurrection token: a kicked owner self-rejoins via9021and silently regains ownership.pg_advisory_xact_lockon a channel-membership namespace, taken as the first statement in bothadd_memberandremove_member. Both read an ownerCOUNTand then write a different row, so READ COMMITTED alone lets two concurrent demotions each observe 2 owners and together leave 0.remove_member'sis_agent_ownerlookup moved before the transaction opens — it borrows a second pool connection, and issuing it while holding the lock could self-deadlock on a small pool. Safe becauseagent_owner_pubkeyis immutable (first-mint-wins).crates/buzz-relay/src/handlers/side_effects.rs:Option— absent means "no role change requested" rather than defaulting toMember.visibility == "private"block, so open channels are covered.OKwhose side effect then fails silently.Verification
Mutation tested — every guard stubbed individually to confirm a test actually dies. Three of eight guards were originally uncovered and survived being disabled with the suite fully green:
unprivileged_member_cannot_demote_a_co_ownerowner_can_still_manage_roles_after_demotion_guardkicked_owner_rejoins_as_member_not_owner+ 3add_memberadvisory lockmembership_writes_serialize_on_the_shared_channel_lockremove_memberadvisory lockremove_member_rejects_an_actor_demoted_while_it_waitedtest_nip29_put_user_without_role_tag_preserves_roletest_nip29_relay_rejects_role_change_by_unprivileged_actortest_nip29_relay_rejects_last_owner_self_demotionThe three gaps shared one cause: every existing test asserts resulting state ("the role did not change"), and the DB guards enforce that state, masking every layer above them. With a relay guard stubbed the relay answers
accepted:trueand logsSide effect failed: access denied: ...while the state assertion still passes — the entire relay validator could be deleted unnoticed. The new relay tests assertaccepted == falseinstead, the one observable only the validator controls. Each new test is verified in both directions: green against the real fix, failing with its intended message when its guard alone is stubbed.Test runs (at
9461eedb):buzz-db, serial: 210 passed / 3 failed — the same 3 failures as cleanmain(202/3), which are pre-existing and unrelated (concurrent_same_owner_create…,create_community_with_owner_is_atomic…,test_usage_metrics_lock_has_single_owner…). +8 = the new tests.e2e_relay --ignored: 40 passed / 3 failed. Cleanmainon the same relay is 35/6 — the same 3 infra failures (test_invite_mint_and_claim…,test_subscription_limit_enforced,test_unarchive_emits_member_added_notification) plus the 3 security tests that fail unpatched and pass here.cargo fmt,clippy,git diff --checkall clean.Live manual drive against a locally running relay, using raw
nak-signed events (thebuzzCLI refuses malformedkind:9000, so the guards have to be exercised directly):9001) → attacker self-rejoins (9021) → returns as member, not admin, and cannot demote the owner.Behavior change to be aware of
Huddle bot-add sends
role="bot". If the target is already an active member at a different role, that is now a role change and requires an elevated actor. Previously it silently re-roled them — the same privesc primitive through a different door, so narrowing it is intended.This does not break the huddle flow in the path that matters: the ephemeral channel add (the one that fails hard) is performed by the host, who created that channel and is therefore its owner — verified live. The parent-channel add is already explicitly best-effort, capturing the error into
parent_errorwith a comment anticipating "may already be member"; adding a non-member agent there still works. Flagging it rather than burying it.Notes
-Sfails in this environment (git tries to load the agent npub as an SSH key file). DCO trailers are present and correct.origin/mainvia--no-ff(not rebased). Upstream had 17 commits, none touching these files, no migration changes.