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:
+3
-1
@@ -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
|
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im
|
||||||
Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
|
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
|
* 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.
|
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
|
* Welche Werkzeuge direkt sichtbar, nur über die Meta-Werkzeuge erreichbar oder abgeschaltet sind, lässt
|
||||||
sich unter Einstellungen → LIT MCP → Tools ändern.
|
sich unter Einstellungen → LIT MCP → Tools ändern.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
|||||||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||||||
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
||||||
|
use LIT\MCP\Server\AuthorizeGate;
|
||||||
use LIT\MCP\Server\DiscoveryCanonical;
|
use LIT\MCP\Server\DiscoveryCanonical;
|
||||||
use LIT\MCP\Server\RequestContext;
|
use LIT\MCP\Server\RequestContext;
|
||||||
use LIT\MCP\Server\ServerRegistrar;
|
use LIT\MCP\Server\ServerRegistrar;
|
||||||
@@ -60,6 +61,7 @@ final class Plugin
|
|||||||
(new RequestContext())->register();
|
(new RequestContext())->register();
|
||||||
(new VisibilityFilter())->register();
|
(new VisibilityFilter())->register();
|
||||||
(new DiscoveryCanonical())->register();
|
(new DiscoveryCanonical())->register();
|
||||||
|
(new AuthorizeGate())->register();
|
||||||
|
|
||||||
$limits = new Limits();
|
$limits = new Limits();
|
||||||
$changeLog = new ChangeLog();
|
$changeLog = new ChangeLog();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user