Skip to content

fix(security): block non-admin Add/Remove/GCE proposals from riding into admin commits#317

Open
dannym-arx wants to merge 3 commits into
masterfrom
fix-106-nonadmin-proposal-smuggle
Open

fix(security): block non-admin Add/Remove/GCE proposals from riding into admin commits#317
dannym-arx wants to merge 3 commits into
masterfrom
fix-106-nonadmin-proposal-smuggle

Conversation

@dannym-arx
Copy link
Copy Markdown
Contributor

@dannym-arx dannym-arx commented May 25, 2026

marmot

Closes marmot-security#106.

A non-admin marmot in a group could call OpenMLS' propose_add_member directly, publish the resulting kind:445 event, and have it sit in every peer's pending-proposal store. The next admin commit (a rename, a self-update, an unrelated add) consumed the whole store with |_| true and signed in the smuggled change. The admin saw "you renamed the group" in their UI while an attacker-controlled identity joined the encrypted group and received the exporter secret.

Build side

prune_unauthorized_pending_proposals drains every non-admin Add, Remove-of-other, or GroupContextExtensions proposal from the pending store before add_members, remove_members, update_group_data_extension, or self_update consume it. Self-scoped proposals (Update, SelfRemove, Remove(self) for the legacy leave path) stay in place, so non-admin self-leave still works.

Receive side

validate_committed_proposal_authorship walks each staged commit's queued proposals and rejects any Add, Remove-of-other, or GCE whose proposer is not in admin_pubkeys. This catches a non-conformant admin client that skips the build-side prune. The new UnauthorizedProposalInCommit error maps to the existing authorization_failed failure category, so peers record the rejection and stay on the old epoch.

Tests

  • ctf_nonadmin_add_proposal_smuggled_into_admin_commit: the reporter's reproducer. The assertion that Mallory is not a member now holds.
  • receive_side_rejects_admin_commit_with_smuggled_nonadmin_add: a peer rejects a smuggled commit even when the committing admin client bypasses the prune.

All 461 mdk-core lib tests pass; just precommit clean.

Per MIP-03 "Commit Messages", Add/Remove/GCE application is admin-only.


Open in Stage

⚠️ Security-sensitive changes

This PR fixes a critical vulnerability (marmot-security#106) where non-admin members could craft OpenMLS proposals that remained in the pending-proposal store and be accidentally or maliciously consumed by an admin-signed commit, allowing unauthorized Adds, Removes-of-others, and GroupContextExtensions to be applied. It implements build- and receive-side guards to prune or reject such unauthorized proposals, and records a clear error when a staged commit bundles an unauthorized proposal.

What changed:

  • Main change: Prevents non-admin Add/Remove-of-other/GCE proposals from being applied via admin commits by pruning unauthorized pending proposals before admin consumption and rejecting staged commits that contain unauthorized proposals.
  • Crates affected: mdk-core only.
  • Added Error::UnauthorizedProposalInCommit(String) to crates/mdk-core/src/error.rs to signal commits bundling unauthorized proposals.
  • Added is_proposal_admin_authorized(), prune_unauthorized_pending_proposals(), and validate_committed_proposal_authorship() in crates/mdk-core/src/messages/validation.rs to implement per-proposal admin authorization checks, build-side pruning, and receive-side validation.
  • Integrated pruning calls into admin-consuming workflows (add_members, remove_members, update_group_data_extension/upgrade_group_capabilities, self_update) in crates/mdk-core/src/groups.rs, and widened get_unknown_extension_from_group_data to pub(crate).
  • Mapped the new UnauthorizedProposalInCommit error to the existing authorization_failed sanitized category in crates/mdk-core/src/messages/error_handling.rs so rejected commits are recorded consistently.
  • Changelog updated to document the security fix.

Security impact:

  • Closes the proposal-smuggling vulnerability that allowed non-admin pending proposals to be included in admin-signed commits.
  • Enforces MIP-03 authorization intent: Add, Remove-of-other, and GroupContextExtensions require an admin proposer; self-scoped proposals (Update, SelfRemove, legacy self-Remove) remain allowed for non-admin self-leave.
  • No changes to cryptographic primitives, key derivation, SQLCipher, file permissions, or exporter-secret handling were introduced; the change enforces authorization checks and improves error reporting.

Protocol changes:

  • Implements MIP-03-style authorization rules for proposal application: non-admin Add/Remove-of-other/GCE proposals are pruned or rejected on both build and receive paths (no MLS algorithm/crypto changes).

API surface:

  • New public error variant: UnauthorizedProposalInCommit(String) added to the Error enum.
  • New crate-visible APIs (pub(crate)/pub(super)): is_proposal_admin_authorized(), prune_unauthorized_pending_proposals(), validate_committed_proposal_authorship().
  • Visibility change: get_unknown_extension_from_group_data() made pub(crate).
  • No breaking public API changes to external crates, storage schema, or UniFFI/FFI boundaries.

Testing:

  • Added two regression tests: ctf_nonadmin_add_proposal_smuggled_into_admin_commit (build-side pruning) and receive_side_rejects_admin_commit_with_smuggled_nonadmin_add (receive-side rejection and authorization_failed recording).
  • All mdk-core tests (461) pass locally; just precommit is clean.

Review Change Stack

…nto admin commits

Closes marmot-security#106.

Before: any non-admin member could call propose_add_member /
propose_remove_member directly against OpenMLS, publish the resulting
kind:445 proposal, and have it sit in every peer's pending-proposal
store until the next admin commit. That next commit (a rename, a
self-update, an unrelated add/remove) consumed the whole store with
|_| true, smuggling the non-admin's membership change in under the
admin's signature. Receive-side validation only checked the committer,
not the proposers, so peers accepted it.

Fix is defense-in-depth on both sides:

- Build side: prune_unauthorized_pending_proposals drains every
  non-admin Add / Remove(other) / GroupContextExtensions proposal from
  the pending store before add_members, remove_members,
  update_group_data_extension, and self_update sweep it into a commit.
- Receive side: validate_committed_proposal_authorship walks every
  staged commit's queued proposals and rejects any Add /
  Remove(other) / GCE whose proposer is not in admin_pubkeys. Self-
  scoped proposals (Update, SelfRemove, Remove(self)) stay allowed so
  legacy non-admin leaves still work.

Adds Error::UnauthorizedProposalInCommit (mapped to the existing
authorization_failed failure category) and two tests: the issue's CTF
test for the build-side prune, and a malicious-admin simulation that
confirms a peer rejects the smuggled commit even when the committing
admin skipped the prune.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 07338816-af0f-4546-8ace-5e8b6f37d674

📥 Commits

Reviewing files that changed from the base of the PR and between 912c54c and 20c0bdf.

📒 Files selected for processing (5)
  • crates/mdk-core/CHANGELOG.md
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/messages/error_handling.rs
  • crates/mdk-core/src/messages/proposal.rs
  • crates/mdk-core/src/messages/validation.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/mdk-core/CHANGELOG.md

📝 Walkthrough

Walkthrough

Adds per-proposal admin-authorship validation and pending-proposal pruning to prevent unauthorized non-admin proposals from being bundled into admin-signed commits; introduces Error::UnauthorizedProposalInCommit, classifies it as authorization_failed, and adds regression tests for send- and receive-side defenses.

Changes

Proposal Authorization and Smuggling Defense

Layer / File(s) Summary
Error definition and import updates
crates/mdk-core/src/error.rs, crates/mdk-core/src/messages/validation.rs
Introduces UnauthorizedProposalInCommit(String) error variant and updates validation imports to include NostrGroupDataExtension.
Per-proposal authorization logic
crates/mdk-core/src/messages/validation.rs
Implements is_proposal_admin_authorized enforcing rules: Update/SelfRemove allowed; Remove(self) allowed for non-admins targeting self; other proposals require proposer credential in group_data.admins.
Commit-time authorization validation
crates/mdk-core/src/messages/validation.rs
Adds validate_committed_proposal_authorship and calls it from validate_commit_authorization for admin commits; rejects staged commits with UnauthorizedProposalInCommit(...) when queued proposals fail authorization.
Pending proposal pruning and group operations
crates/mdk-core/src/messages/validation.rs, crates/mdk-core/src/groups.rs
Adds prune_unauthorized_pending_proposals and invokes pruning before add, remove, group-data-extension updates, and self-update flows. Makes get_unknown_extension_from_group_data pub(crate).
Error categorization for external APIs
crates/mdk-core/src/messages/error_handling.rs
Classifies Error::UnauthorizedProposalInCommit under sanitized reason "authorization_failed".
Security regression tests
crates/mdk-core/src/messages/proposal.rs
Adds two tests validating that a non-admin Add cannot be smuggled into an admin commit and that receivers reject such commits with Unprocessable and authorization_failed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • marmot-protocol/marmot-security#106: This PR implements the send- and receive-side fixes described in the issue (admin-authorship checks, pending-proposal pruning, and UnauthorizedProposalInCommit).

Possibly related PRs

  • marmot-protocol/mdk#256: Related changes to validate_commit_authorization and receiver-side admin invariant/depletion checks.
  • marmot-protocol/mdk#266: Overlaps in admin/pending-proposal authorization paths and related error handling in error.rs/groups.rs.

Suggested labels

security, mls-protocol, breaking-change

Suggested reviewers

  • erskingardner
  • mubarakcoded
  • jgmontoya
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: preventing non-admin Add/Remove/GCE proposals from being included in admin commits, which is the central change across error handling, validation, and group operation functions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Sensitive Identifier Leakage ✅ Passed No sensitive identifiers exposed. Only non-sensitive proposal types and leaf position indices are logged in tracing/format/error messages.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-106-nonadmin-proposal-smuggle

Comment @coderabbitai help to get the list of available commands and usage tips.

@stage-review
Copy link
Copy Markdown

stage-review Bot commented May 25, 2026

Ready to review this PR? Stage has broken it down into 6 individual chapters for you:

Title
1 Define unauthorized proposal error and mapping
2 Implement proposal authorization and pruning logic
3 Integrate validation into commit processing
4 Prune pending proposals before admin actions
5 Verify security fix with integration tests
6 Document security fix in CHANGELOG
Open in Stage

Chapters generated by Stage for commit 20c0bdf on May 25, 2026 10:23am UTC.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 25, 2026

✅ Coverage: 94.51% → 94.59% (+0.08%)

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/mdk-core/src/messages/error_handling.rs (1)

1042-1117: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add test coverage for Error::UnauthorizedProposalInCommit.

The test covers Error::CommitFromNonAdmin but does not test the newly added Error::UnauthorizedProposalInCommit(_) variant. Add a test assertion to verify it maps to "authorization_failed".

🧪 Proposed test addition
     assert_eq!(
         MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::CommitFromNonAdmin),
         "authorization_failed"
     );
+
+    assert_eq!(
+        MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::UnauthorizedProposalInCommit(
+            "test_proposal".to_string()
+        )),
+        "authorization_failed"
+    );

     // Test catch-all for unmapped variants (should return "processing_failed")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mdk-core/src/messages/error_handling.rs` around lines 1042 - 1117, The
test function test_sanitize_error_reason_all_variants misses coverage for the
new Error::UnauthorizedProposalInCommit variant; add an assertion calling
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::UnauthorizedProposalInCommit(some_value))
and assert it equals "authorization_failed" (choose any representative inner
value, e.g., 1 or a tuple expected by the variant) alongside the existing
CommitFromNonAdmin check so the sanitize_error_reason mapping for
UnauthorizedProposalInCommit is validated.
🧹 Nitpick comments (1)
crates/mdk-core/src/messages/proposal.rs (1)

971-973: ⚡ Quick win

Hoist these imports to the test module scope.

These local use statements should live in the #[cfg(test)] mod tests import block instead of inside individual test functions.

As per coding guidelines All use statements must be placed at the TOP of their containing scope, never inside functions, methods, blocks, or individual test functions.

Also applies to: 1091-1093, 1201-1201

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mdk-core/src/messages/proposal.rs` around lines 971 - 973, Several
tests (e.g., ctf_nonadmin_add_proposal_smuggled_into_admin_commit and the tests
around lines you noted) have local `use` statements inside the test functions;
move those imports (for example `use crate::groups::NostrGroupDataUpdate` and
the other per-test `use` lines at 1091-1093 and 1201) up into the `#[cfg(test)]
mod tests { ... }` module-level import block so all `use` statements are at the
top of their containing scope rather than inside the individual test functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1333-1335: The upgrade_group_capabilities path is missing the
pending-proposal scrub used elsewhere; call
prune_unauthorized_pending_proposals(mls_group, group_id)? at the start of
upgrade_group_capabilities (the same way update_group_context_extensions does)
before reaching the commit API that applies admin capability upgrades, so any
non-admin Add/Remove(other)/GCE proposals are removed from the pending store
prior to creating/applying the commit.

In `@crates/mdk-core/src/messages/proposal.rs`:
- Around line 1047-1079: Add assertions that Alice's and Carol's replicated
group data actually reflect the rename: call alice_mdk.get_group_data(&group_id)
and carol_mdk.get_group_data(&group_id) (or the equivalent accessor in this
module) after processing the rename and assert the group's name equals "Renamed"
on both replicas; reference the existing rename variable
(rename.evolution_event) and group_id so the checks run immediately after
carol_mdk.process_message(&rename.evolution_event).

In `@crates/mdk-core/src/messages/validation.rs`:
- Around line 486-494: In prune_unauthorized_pending_proposals, do not just warn
and continue when mls_group.remove_pending_proposal(self.provider.storage(),
&proposal_ref) returns Err; instead propagate that error back to the caller
(e.g. return Err(...) or map it into the function's error type) so the prune
failure aborts the operation and prevents the unauthorized proposal from
remaining in the pending store; update the match/if-let handling around
remove_pending_proposal to return the error immediately rather than logging and
continuing.
- Around line 474-484: The current prune_unauthorized_pending_proposals only
checks is_proposal_admin_authorized which applies admin semantics and thus
leaves admin-only proposals when a non-admin performs self_update; change the
pruning policy to consider the caller/operation: update
prune_unauthorized_pending_proposals (or add a new parameter such as
is_admin_or_self_update_flag) so it rejects proposals that require admin rights
(Add, Remove(other), GroupContextExtensions) when the caller is not an admin or
when called from a non-admin self_update, and only allows self-scoped proposals
(e.g., UpdateSelf or proposals authored/targeting the caller) to survive;
alternatively, when invoked from self_update by a non-admin, bypass consuming
the proposal store and only include proposals with self-scope. Ensure you
reference pending_proposals(), is_proposal_admin_authorized(), and the
self_update call site so the filter logic distinguishes admin vs non-admin flows
and drops CommitFromNonAdmin-prone proposals.

---

Outside diff comments:
In `@crates/mdk-core/src/messages/error_handling.rs`:
- Around line 1042-1117: The test function
test_sanitize_error_reason_all_variants misses coverage for the new
Error::UnauthorizedProposalInCommit variant; add an assertion calling
MDK::<MdkMemoryStorage>::sanitize_error_reason(&Error::UnauthorizedProposalInCommit(some_value))
and assert it equals "authorization_failed" (choose any representative inner
value, e.g., 1 or a tuple expected by the variant) alongside the existing
CommitFromNonAdmin check so the sanitize_error_reason mapping for
UnauthorizedProposalInCommit is validated.

---

Nitpick comments:
In `@crates/mdk-core/src/messages/proposal.rs`:
- Around line 971-973: Several tests (e.g.,
ctf_nonadmin_add_proposal_smuggled_into_admin_commit and the tests around lines
you noted) have local `use` statements inside the test functions; move those
imports (for example `use crate::groups::NostrGroupDataUpdate` and the other
per-test `use` lines at 1091-1093 and 1201) up into the `#[cfg(test)] mod tests
{ ... }` module-level import block so all `use` statements are at the top of
their containing scope rather than inside the individual test functions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 401e8976-71b9-49a9-8d3c-63be20c7bf81

📥 Commits

Reviewing files that changed from the base of the PR and between 25005ea and 53db7d2.

📒 Files selected for processing (5)
  • crates/mdk-core/src/error.rs
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/messages/error_handling.rs
  • crates/mdk-core/src/messages/proposal.rs
  • crates/mdk-core/src/messages/validation.rs

Comment thread crates/mdk-core/src/groups.rs
Comment thread crates/mdk-core/src/messages/proposal.rs
Comment thread crates/mdk-core/src/messages/validation.rs
Comment thread crates/mdk-core/src/messages/validation.rs Outdated
…, propagate prune errors, strengthen tests

Review pass on PR #317. Four still-valid findings applied:

- Cover upgrade_group_capabilities with prune_unauthorized_pending_proposals
  so the GCE commit it produces cannot sweep non-admin proposals.
- Propagate remove_pending_proposal storage failures from the prune helper
  instead of warn-and-continue: a silent failure would leave the smuggled
  proposal in the store for the next sweep, defeating the build-side guard.
- Strengthen ctf_nonadmin_add_proposal_smuggled_into_admin_commit by
  asserting the rename actually landed on both replicas, so a regression
  that dropped the legitimate change along with the prune cannot pass.
- Add sanitize_error_reason coverage for the new
  UnauthorizedProposalInCommit variant.

Skipped (with reasons):
- Non-admin self_update sweeping admin-authored proposals causing
  CommitFromNonAdmin on peers: pre-existing behavior, orthogonal to #106
  (no privilege escalation). Belongs in a separate non-admin commit
  hygiene issue.
- Move per-test `use` statements to the module-level import block:
  stylistic. Per-test `use` keeps each test self-contained; not a
  correctness issue.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant