fix(security): refuse OAuth authorize/consent for users without access (audit L1)

mcp-oauth has no filter for this, so AuthorizeGate runs on
template_redirect before its router and stops the browser endpoints
(authorize, authorize-callback, consent) for logged-in users without
lit_mcp_use with "Dein Benutzer hat keinen Zugriff auf LIT MCP." (403).
Login redirect, token and revoke stay mcp-oauth's.

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-26 19:30:54 +02:00
co-authored by Claude Opus 5.5
parent bb61c370a0
commit 4d4bf57c9f
4 changed files with 127 additions and 1 deletions
+3 -1
View File
@@ -32,7 +32,9 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im
Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
* Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der
Datenbank werden nicht verändert. Nicht freigegebene Benutzer sehen beim Verbinden weder die Werkzeugliste
Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die
Anmeldung beim Verbinden endet mit „Dein Benutzer hat keinen Zugriff auf LIT MCP.“, es entsteht kein
Anwendungspasswort. Verlieren bereits verbundene Benutzer die Freigabe, sehen sie weder die Werkzeugliste
noch Angaben zur Website – nur den Hinweis, dass ihr Konto nicht freigegeben ist.
* Welche Werkzeuge direkt sichtbar, nur über die Meta-Werkzeuge erreichbar oder abgeschaltet sind, lässt
sich unter Einstellungen → LIT MCP → Tools ändern.
+2
View File
@@ -30,6 +30,7 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Server\AuthorizeGate;
use LIT\MCP\Server\DiscoveryCanonical;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar;
@@ -60,6 +61,7 @@ final class Plugin
(new RequestContext())->register();
(new VisibilityFilter())->register();
(new DiscoveryCanonical())->register();
(new AuthorizeGate())->register();
$limits = new Limits();
$changeLog = new ChangeLog();
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Server;
use LIT\MCP\Access;
/**
* Stops mcp-oauth's browser flow for logged-in users without lit_mcp_use (security audit L1).
* mcp-oauth only checks is_user_logged_in() and offers no filter, so this runs on
* template_redirect before its router (priority 10): without it any customer or subscriber
* could complete the consent and get an application password, tokens and sessions, even
* though the tools would refuse them. Not logged in, the login redirect stays mcp-oauth's;
* token and revoke have no browser session and are left alone.
*/
final class AuthorizeGate
{
/** mcp-oauth's query var (WPMedia\MCP\OAuth\Auth\Rewrite::OAUTH_QUERY_VAR). */
private const QUERY_VAR = 'mcp_oauth_endpoint';
/** Endpoints a user reaches in the browser before a connection exists. */
private const USER_FACING = ['authorize', 'authorize-callback', 'consent'];
public function register(): void
{
add_action('template_redirect', [$this, 'check'], 1);
}
public function check(): void
{
$endpoint = (string) get_query_var(self::QUERY_VAR, '');
if (! in_array($endpoint, self::USER_FACING, true) || ! is_user_logged_in() || Access::currentUserMayUse()) {
return;
}
wp_die(
esc_html(__('Dein Benutzer hat keinen Zugriff auf LIT MCP.', 'wp-lit-mcp')),
esc_html(__('Kein Zugriff', 'wp-lit-mcp')),
['response' => 403]
);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Server;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Access;
use LIT\MCP\Server\AuthorizeGate;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
/**
* Security audit L1: a logged-in user without an allowed role must not complete mcp-oauth's
* authorize/consent flow (no application password, tokens or sessions for them).
*/
final class AuthorizeGateTest extends TestCase
{
private function given(string $endpoint, bool $loggedIn, bool $allowed): void
{
Functions\when('get_query_var')->alias(
static fn (string $var, $default = '') => $var === 'mcp_oauth_endpoint' ? $endpoint : $default
);
Functions\when('is_user_logged_in')->justReturn($loggedIn);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === Access::CAPABILITY && $allowed);
Functions\when('__')->returnArg();
Functions\when('esc_html')->returnArg();
}
public function testRunsBeforeMcpOauthsOwnHandler(): void
{
Actions\expectAdded('template_redirect')->once()->with(Mockery::type('array'), 1);
(new AuthorizeGate())->register();
}
/** @return array<string, array{string}> */
public static function userFacingEndpoints(): array
{
return ['authorize' => ['authorize'], 'authorize-callback' => ['authorize-callback'], 'consent' => ['consent']];
}
/** @dataProvider userFacingEndpoints */
public function testUsersWithoutAccessAreStoppedWithAClearMessage(string $endpoint): void
{
$this->given($endpoint, true, false);
Functions\expect('wp_die')->once()->with(
'Dein Benutzer hat keinen Zugriff auf LIT MCP.',
Mockery::type('string'),
Mockery::on(static fn (array $args): bool => ($args['response'] ?? null) === 403)
);
(new AuthorizeGate())->check();
}
public function testAllowedUsersLoginAndOtherEndpointsPass(): void
{
Functions\expect('wp_die')->never();
$this->given('consent', true, true);
(new AuthorizeGate())->check();
$this->given('authorize', false, false); // mcp-oauth sends them to the login first
(new AuthorizeGate())->check();
foreach (['token', 'revoke', ''] as $endpoint) { // no browser session there
$this->given($endpoint, true, false);
(new AuthorizeGate())->check();
}
}
public function testQueryVarMatchesMcpOauth(): void
{
$gate = new \ReflectionClass(AuthorizeGate::class);
self::assertSame(\WPMedia\MCP\OAuth\Auth\Rewrite::OAUTH_QUERY_VAR, $gate->getConstant('QUERY_VAR'));
}
}