30 days free. No credit card. Full access from the moment you connect your site.

Start free trial

v0.1.7 — F033 F030 permission_callback wrapper security fix

🔒 Security fix — please upgrade

Severity: P1 (privilege escalation) — any authenticated user (including subscriber) could invoke any registered ability via mcp-adapter/execute-ability on the default MCP server, bypassing the Abilities-tab exposure gate.

What changed

Two mutually reinforcing bugs in PermissionOverrideProcessor::inject_override combined into a plugin-wide permission_callback bypass:

  1. Wrapper closure dropped its arguments. Declared as static function () use ( ... ) — zero parameters — the closure silently discarded every arg the caller passed. Downstream callbacks reading $input (notably Execute::check_permission looking up $input['ability_name']) received an empty array and returned WP_Error( 'missing_ability_name' ).
  2. WP_Error return coerced to boolean true. call_original did return (bool) call_user_func( $original );. In PHP, (bool) $any_object is always true — so the WP_Error object became true, and the vendor's if ( true !== $permission ) check in ToolsHandler::call_tool:148 read that as "permission granted" and proceeded to execute().

Net impact: reproducible live with a subscriber-level user invoking acrossai-abilities-manager/site-title-get even when the ability was mcp.public=false at registration AND the operator had explicitly disabled it (is_exposed=0) in the Abilities tab. Response was 200 with the site title returned.

Fix (PR #45)

  • Closure now static function ( ...$callback_args ) use ( ... ) and forwards $callback_args to self::call_original( $original, $callback_args ) on every fall-through path.
  • call_original returns bool|\WP_Error — WP_Error results propagate unchanged; only scalar returns are coerced to bool.

The six defensive layers of DEC-F030-PERMISSION-CALLBACK-OPERATOR-OPT-IN-BYPASS are unchanged — only the fall-through path is affected.

Regression tests

Three new tests in PermissionOverrideProcessorTest.php:

  • test_closure_forwards_args_to_original_callback — asserts args flow to the original.
  • test_wp_error_from_original_is_preserved_not_coerced_to_true — asserts WP_Error survives the wrapper.
  • test_wrapper_preserves_role_gated_denials — @dataProvider over subscriber / contributor / author / editor / administrator; only administrator (with manage_options) is allowed; every other role is correctly denied.

Durable memory

B40 / B-WRAPPER-CLOSURE-MUST-FORWARD-ARGS-AND-PRESERVE-WP-ERROR captured in docs/memory/BUGS.md — generalizable pattern for any closure wrapping a user callback (permission_callback, execute_callback, filter/action decorators, plugin bridges).

Follow-up work

Issue #46 — proposed F034 filter-time eligibility gate that would skip installing the wrapper entirely for abilities that could never satisfy F030's six defensive layers, eliminating the wrapper-bug class for the vast majority of abilities.

No breaking changes

Six-layer bypass semantics preserved. No schema changes. No admin-visible UI changes. Safe to upgrade in place.

References

  • Fix PR: #45 (merged as db1ff2e)
  • Release PR: #47 (merged as 953e7bd)
  • Spec-kit docs: specs/033-f030-permission-callback-wrapper-fix/ + docs/planings-tasks/033-f030-permission-callback-wrapper-fix.md
  • Original F030 spec: specs/030-per-server-permission-override/

Keep reading