fix: fail closed on permission() exceptions, harden RequestContext flag reset

- AbilityRegistrar: wrap permission_callback in try/catch(Throwable) like
  execute_callback, so a throwing permission() denies (Errors::internal())
  instead of skipping rest_request_after_callbacks and leaving
  RequestContext's flag set for a later request in the same PHP worker.
- RequestContext::recordRoute() now recomputes the flag (and bearer token)
  unconditionally on every matched REST request instead of only setting it
  true for our own route, so a stale true left behind by an earlier request
  is reset by the next non-matching one (fail closed, defense in depth).
- RequestContext::recordRoute() matches the Authorization "Bearer" scheme
  case-insensitively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 21:51:41 +02:00
co-authored by Claude Opus 5.5
parent 720d0891c4
commit 1df655a74e
4 changed files with 82 additions and 11 deletions
+31
View File
@@ -40,6 +40,18 @@ final class RequestContextTest extends TestCase
self::assertFalse(RequestContext::isOAuthServerRequest());
}
public function testRecordRouteResetsAStaleFlagOnALaterNonMatchingRequest(): void
{
// Simulates rest_request_after_callbacks never having fired for a
// previous OAuth server request (e.g. a fatal error mid-dispatch),
// leaving the flag orphaned as true in a persistent PHP worker.
RequestContext::reset(true);
RequestContext::recordRoute(null, [], new WP_REST_Request('/wp/v2/users/me'));
self::assertFalse(RequestContext::isOAuthServerRequest());
}
public function testRecordRouteReturnsResponseUnchanged(): void
{
$response = new stdClass();
@@ -87,6 +99,25 @@ final class RequestContextTest extends TestCase
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
}
public function testClientNameMatchesTheBearerSchemeCaseInsensitively(): void
{
Functions\when('get_option')->justReturn('test-secret');
$token = JWT::encode([
'sub' => '3',
'app_pass_id' => 'uuid-unknown',
'client_id' => 'https://claude.ai/oauth/mcp-oauth-client-metadata',
'exp' => time() + 60,
], 'test-secret');
RequestContext::recordRoute(
null,
[],
new WP_REST_Request(self::ROUTE, ['authorization' => 'bearer ' . $token])
);
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
}
public function testClientNameIsEmptyForAnInvalidToken(): void
{
Functions\when('get_option')->justReturn('test-secret');