Skip to content

Security Fix: Operator precedence bug in UpdateDelegate revoke_plugin_authority#253

Merged
blockiosaurus merged 1 commit intometaplex-foundation:mainfrom
AdeshAtole:fix/operator-precedence-revoke
Feb 11, 2026
Merged

Security Fix: Operator precedence bug in UpdateDelegate revoke_plugin_authority#253
blockiosaurus merged 1 commit intometaplex-foundation:mainfrom
AdeshAtole:fix/operator-precedence-revoke

Conversation

@AdeshAtole
Copy link
Contributor

Security Finding

[MEDIUM] UpdateDelegate Operator Precedence Bug in Revoke Authority

File: programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs:149-154

Issue:
The boolean condition in validate_revoke_plugin_authority has an operator precedence bug:

// Before (BUG): && binds tighter than ||
if A || B && C && D
// Evaluates as: A || (B && C && D)
// D (manager check) only applies to the additional_delegates branch

// After (FIXED):
if (A || B && C) && D
// D (manager check) applies to BOTH branches

Impact: UpdateDelegate can revoke authority on owner-managed plugins (FreezeDelegate, TransferDelegate) that it should not control. Only UpdateAuthority-managed plugins should be revocable by the UpdateDelegate.

Fix: Added explicit parentheses so plugin.manager() == Authority::UpdateAuthority applies to both the resolved_authorities and additional_delegates branches.


Found during a Solana security audit

Due to Rust's operator precedence (&& binds tighter than ||), the
condition `A || B && C && D` was evaluated as `A || (B && C && D)`
instead of the intended `(A || (B && C)) && D`.

This meant the `plugin.manager() == Authority::UpdateAuthority` check
only applied to the additional_delegates branch, not the main
resolved_authorities branch. As a result, UpdateDelegate could revoke
authority on owner-managed plugins (FreezeDelegate, TransferDelegate)
that it should not control.

Added explicit parentheses to ensure the manager check applies to
both branches.
@vercel
Copy link

vercel bot commented Feb 11, 2026

@AdeshAtole is attempting to deploy a commit to the Metaplex Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a critical security issue in authority validation logic that ensures authorization checks are properly and consistently enforced across all delegate management operations, preventing unintended authorization bypasses.

Walkthrough

This change is a security fix that adds explicit parentheses to correct an operator-precedence issue in the authority validation logic, ensuring the UpdateAuthority check applies correctly to both authority branches in the revoke-validation condition.

Changes

Cohort / File(s) Summary
Security Fix - Authority Validation
programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs
Added explicit parentheses to correct operator-precedence flaw in revoke-validation condition. The UpdateAuthority check now correctly applies to both the resolved authorities and additional delegates branches. Includes clarifying SECURITY FIX comment documenting the issue.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main security fix: an operator precedence bug in the UpdateDelegate revoke_plugin_authority function.
Description check ✅ Passed The description comprehensively explains the security issue, including the bug details, impact, and fix applied to address the operator precedence problem.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link

@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: 3

Caution

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

⚠️ Outside diff range comments (1)
programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs (1)

243-268: ⚠️ Potential issue | 🟠 Major

No test coverage for the security fix.

This is a security-critical change to authority validation logic, but no new tests were added to verify:

  1. That validate_revoke_plugin_authority now rejects revocation of owner-managed plugins (e.g., FreezeDelegate) when only resolved_authorities matches.
  2. That it still approves revocation of update-authority-managed plugins.
  3. That additional_delegates cannot revoke authority on the UpdateDelegate plugin itself.

Regression tests for security fixes are essential — without them, a future refactor could silently reintroduce the precedence bug.

Would you like me to generate unit tests covering these scenarios, or open an issue to track this?

🤖 Fix all issues with AI agents
In `@programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs`:
- Line 155: The conditional expression in validate (the long line containing
self.additional_delegates.contains(ctx.authority_info.key) &&
PluginType::from(plugin) != PluginType::UpdateDelegate) is excessively long and
hard to read; reformat it to multiple lines consistent with other validators
(e.g., break after each boolean operator and align predicates), for example
split the OR branches and put the contains(...) and PluginType::from(...)
comparisons on separate lines so the whole condition is under 120 chars while
preserving exact logic and precedence for self.additional_delegates,
ctx.authority_info.key, PluginType::from(plugin), and
PluginType::UpdateDelegate.
- Around line 144-148: Update the security-fix comment to accurately reflect
both changes: note that parentheses were added to correct operator precedence
and that an additional self-revoke guard was introduced by checking
PluginType::from(plugin) != PluginType::UpdateDelegate to prevent UpdateDelegate
from revoking itself for additional_delegates; reference the condition change
and the PluginType::UpdateDelegate guard so readers know this is more than a
parenthesization fix in the UpdateDelegate logic.
- Around line 149-157: Add an inline comment in the authorize validation logic
(near validate_revoke_plugin_authority and the existing "SECURITY FIX" block)
explaining the intentional asymmetry: clarify that PluginType::UpdateDelegate is
globally disallowed from being approved (see validate_approve_plugin_authority
and the PluginType::UpdateDelegate guard), but the owner (resolved_authorities /
ctx.self_authority) is still allowed to revoke UpdateDelegate permissions (the
branch checking resolved_authorities and plugin.manager() ==
Authority::UpdateAuthority) to ensure the owner retains ultimate control;
mention that additional_delegates (self.additional_delegates) are intentionally
prevented from approving or revoking UpdateDelegate and reference
plugin.manager() == Authority::UpdateAuthority and the additional_delegates
check to make the rationale clear for future maintainers.

Comment on lines +144 to +148
// SECURITY FIX: Added explicit parentheses to fix operator precedence.
// Previously, `&& plugin.manager() == Authority::UpdateAuthority` only bound
// to the additional_delegates branch due to && having higher precedence than ||.
// This allowed UpdateDelegate to revoke authority on owner-managed plugins
// (FreezeDelegate, TransferDelegate) which it should not control.
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Security fix comment is slightly misleading about the scope of changes.

The comment states the fix is about operator precedence only, but a new condition (PluginType::from(plugin) != PluginType::UpdateDelegate) was also introduced on line 155 — this is not merely a parenthesization change. The comment should mention that the UpdateDelegate self-revoke guard was also added for additional delegates.

Suggested comment update
-        // SECURITY FIX: Added explicit parentheses to fix operator precedence.
-        // Previously, `&& plugin.manager() == Authority::UpdateAuthority` only bound
-        // to the additional_delegates branch due to && having higher precedence than ||.
-        // This allowed UpdateDelegate to revoke authority on owner-managed plugins
-        // (FreezeDelegate, TransferDelegate) which it should not control.
+        // SECURITY FIX: (1) Added explicit parentheses to fix operator precedence so
+        // `plugin.manager() == Authority::UpdateAuthority` applies to BOTH the
+        // resolved_authorities and additional_delegates branches. Previously, && binding
+        // tighter than || meant the manager check only applied to the additional_delegates
+        // branch, allowing revocation of owner-managed plugins (FreezeDelegate,
+        // TransferDelegate). (2) Added a guard preventing additional delegates from
+        // revoking authority on the UpdateDelegate plugin itself.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// SECURITY FIX: Added explicit parentheses to fix operator precedence.
// Previously, `&& plugin.manager() == Authority::UpdateAuthority` only bound
// to the additional_delegates branch due to && having higher precedence than ||.
// This allowed UpdateDelegate to revoke authority on owner-managed plugins
// (FreezeDelegate, TransferDelegate) which it should not control.
// SECURITY FIX: (1) Added explicit parentheses to fix operator precedence so
// `plugin.manager() == Authority::UpdateAuthority` applies to BOTH the
// resolved_authorities and additional_delegates branches. Previously, && binding
// tighter than || meant the manager check only applied to the additional_delegates
// branch, allowing revocation of owner-managed plugins (FreezeDelegate,
// TransferDelegate). (2) Added a guard preventing additional delegates from
// revoking authority on the UpdateDelegate plugin itself.
🤖 Prompt for AI Agents
In `@programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs`
around lines 144 - 148, Update the security-fix comment to accurately reflect
both changes: note that parentheses were added to correct operator precedence
and that an additional self-revoke guard was introduced by checking
PluginType::from(plugin) != PluginType::UpdateDelegate to prevent UpdateDelegate
from revoking itself for additional_delegates; reference the condition change
and the PluginType::UpdateDelegate guard so readers know this is more than a
parenthesization fix in the UpdateDelegate logic.

Comment on lines +149 to 157
if ((ctx.resolved_authorities.is_some()
&& ctx
.resolved_authorities
.unwrap()
.contains(ctx.self_authority))
// Or the authority is one of the additional delegates.
|| (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate)
// And it's an authority-managed plugin.
|| (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate))
// And it's an authority-managed plugin (applies to BOTH branches).
&& plugin.manager() == Authority::UpdateAuthority
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare the boolean structure of validate_approve_plugin_authority vs validate_revoke_plugin_authority
echo "=== validate_approve_plugin_authority ==="
rg -n -A 25 'fn validate_approve_plugin_authority' programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs

echo ""
echo "=== validate_revoke_plugin_authority ==="
rg -n -A 25 'fn validate_revoke_plugin_authority' programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs

Repository: metaplex-foundation/mpl-core

Length of output: 2690


The asymmetry between validate_approve_plugin_authority and validate_revoke_plugin_authority is real, but the design intent is underdocumented.

The code confirms the asymmetry you identified: in validate_revoke_plugin_authority, the PluginType::UpdateDelegate guard is nested inside the additional_delegates branch only, allowing resolved_authorities (the owner) to revoke authority on UpdateDelegate while blocking additional_delegates from doing so. By contrast, validate_approve_plugin_authority prevents both from approving authority on UpdateDelegate.

The "SECURITY FIX" comment (lines 144–148) explains why parentheses were added—to ensure that the plugin.manager() == Authority::UpdateAuthority check applies to both branches and prevent UpdateDelegate from revoking authority on owner-managed plugins. However, this comment does not explicitly explain why the revoke logic allows the owner to revoke UpdateDelegate authority while the approve logic blocks it entirely.

The design appears intentional: the owner should retain ultimate authority to revoke UpdateDelegate permissions, while delegates should be restricted. However, add an inline comment explaining this design choice—specifically why revoke permits the owner to revoke UpdateDelegate authority when approve does not permit approval of it at all—to prevent future confusion.

🤖 Prompt for AI Agents
In `@programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs`
around lines 149 - 157, Add an inline comment in the authorize validation logic
(near validate_revoke_plugin_authority and the existing "SECURITY FIX" block)
explaining the intentional asymmetry: clarify that PluginType::UpdateDelegate is
globally disallowed from being approved (see validate_approve_plugin_authority
and the PluginType::UpdateDelegate guard), but the owner (resolved_authorities /
ctx.self_authority) is still allowed to revoke UpdateDelegate permissions (the
branch checking resolved_authorities and plugin.manager() ==
Authority::UpdateAuthority) to ensure the owner retains ultimate control;
mention that additional_delegates (self.additional_delegates) are intentionally
prevented from approving or revoking UpdateDelegate and reference
plugin.manager() == Authority::UpdateAuthority and the additional_delegates
check to make the rationale clear for future maintainers.

// Or the authority is one of the additional delegates.
|| (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate)
// And it's an authority-managed plugin.
|| (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate))
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Line is excessively long — break for readability.

Line 155 is well over 120 characters. The other validation methods in this file (e.g., validate_add_plugin, validate_approve_plugin_authority) break their conditions across multiple lines. Keeping a consistent style also reduces the chance of future precedence bugs in this security-critical expression.

Suggested formatting
-            || (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate))
+            || (self.additional_delegates.contains(ctx.authority_info.key)
+                && PluginType::from(plugin) != PluginType::UpdateDelegate))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
|| (self.additional_delegates.contains(ctx.authority_info.key) && PluginType::from(plugin) != PluginType::UpdateDelegate))
|| (self.additional_delegates.contains(ctx.authority_info.key)
&& PluginType::from(plugin) != PluginType::UpdateDelegate))
🤖 Prompt for AI Agents
In `@programs/mpl-core/src/plugins/internal/authority_managed/update_delegate.rs`
at line 155, The conditional expression in validate (the long line containing
self.additional_delegates.contains(ctx.authority_info.key) &&
PluginType::from(plugin) != PluginType::UpdateDelegate) is excessively long and
hard to read; reformat it to multiple lines consistent with other validators
(e.g., break after each boolean operator and align predicates), for example
split the OR branches and put the contains(...) and PluginType::from(...)
comparisons on separate lines so the whole condition is under 120 chars while
preserving exact logic and precedence for self.additional_delegates,
ctx.authority_info.key, PluginType::from(plugin), and
PluginType::UpdateDelegate.

@blockiosaurus blockiosaurus merged commit 1a68114 into metaplex-foundation:main Feb 11, 2026
21 of 24 checks passed
@blockiosaurus
Copy link
Contributor

Thank you for this excellent PR! We appreciate the clear explanation and the provided fix with comments. However, in the future please disclose all security issues through bounty@metaplex.foundation rather than publicly posting the 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.

2 participants