diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index 21441a117b..34e20d2617 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -13,26 +13,10 @@ class ApiAllowed if (isCloud()) { return $next($request); } - $settings = instanceSettings(); - if ($settings->is_api_enabled === false) { + if (instanceSettings()->is_api_enabled === false) { return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } - if ($settings->allowed_ips) { - // Check for special case: 0.0.0.0 means allow all - if (trim($settings->allowed_ips) === '0.0.0.0') { - return $next($request); - } - - $allowedIps = explode(',', $settings->allowed_ips); - $allowedIps = array_map('trim', $allowedIps); - $allowedIps = array_filter($allowedIps); // Remove empty entries - - if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } - } - - return $next($request); + return app(ApiIpAllowed::class)->handle($request, $next); } } diff --git a/app/Http/Middleware/ApiIpAllowed.php b/app/Http/Middleware/ApiIpAllowed.php new file mode 100644 index 0000000000..ede32567d0 --- /dev/null +++ b/app/Http/Middleware/ApiIpAllowed.php @@ -0,0 +1,31 @@ +allowed_ips); + // Empty or 0.0.0.0 means allow all + if ($allowedIps === '' || $allowedIps === '0.0.0.0') { + return $next($request); + } + + $allowedIps = array_filter(array_map('trim', explode(',', $allowedIps))); + + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); + } + + return $next($request); + } +} diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 0e6242d572..aa094316c0 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -89,8 +89,8 @@ ['value' => false, 'label' => 'Disabled'], ]" />
-
diff --git a/routes/ai.php b/routes/ai.php index d79ff0ebae..d3f9bf83d2 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -1,7 +1,8 @@ middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']); + ->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'mcp.team.enabled']); diff --git a/routes/api.php b/routes/api.php index a44b95a1d2..b0eba12a6b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,6 +40,7 @@ use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\VolumeBackupsController; use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; +use App\Http\Middleware\ApiIpAllowed; use Illuminate\Support\Facades\Route; Route::get('/health', [OtherController::class, 'healthcheck']); @@ -53,7 +54,7 @@ Route::post('/feedback', [OtherController::class, 'feedback']) ->middleware('throttle:feedback'); Route::group([ - 'middleware' => ['auth:sanctum', 'api.token.team', 'api.ability:write'], + 'middleware' => ['auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write'], 'prefix' => 'v1', ], function () { Route::get('/enable', [OtherController::class, 'post_required']); diff --git a/tests/Feature/AdvisorySecurityRegressionTest.php b/tests/Feature/AdvisorySecurityRegressionTest.php index 452308d22a..4c14ce379e 100644 --- a/tests/Feature/AdvisorySecurityRegressionTest.php +++ b/tests/Feature/AdvisorySecurityRegressionTest.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Http\Middleware\ApiAllowed; +use App\Http\Middleware\ApiIpAllowed; use App\Models\EnvironmentVariable; use App\Models\InstanceSettings; use App\Models\Service; @@ -49,7 +50,7 @@ it('does not require email verification for protected web routes', function () { $this->actingAs($user)->get('/analytics')->assertOk(); }); -it('does not apply the REST API allowlist to MCP and MCP switch routes', function () { +it('applies only the IP allow-list, not the REST API switch, to MCP and MCP switch routes', function () { $mcp = Route::getRoutes()->match(Request::create('/mcp', 'POST')); $enable = Route::getRoutes()->match(Request::create('/api/v1/mcp/enable', 'POST')); $disable = Route::getRoutes()->match(Request::create('/api/v1/mcp/disable', 'POST')); @@ -57,7 +58,10 @@ it('does not apply the REST API allowlist to MCP and MCP switch routes', functio expect($mcp)->not->toBeNull() ->and($mcp->gatherMiddleware())->not->toContain(ApiAllowed::class) ->and($enable->gatherMiddleware())->not->toContain(ApiAllowed::class) - ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class); + ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class) + ->and($mcp->gatherMiddleware())->toContain(ApiIpAllowed::class) + ->and($enable->gatherMiddleware())->toContain(ApiIpAllowed::class) + ->and($disable->gatherMiddleware())->toContain(ApiIpAllowed::class); }); it('throttles only failed authentication on manual webhook routes', function (string $provider) { diff --git a/tests/Feature/LoginAndMcpAccessTest.php b/tests/Feature/LoginAndMcpAccessTest.php index 5d0325f291..1c63176fcc 100644 --- a/tests/Feature/LoginAndMcpAccessTest.php +++ b/tests/Feature/LoginAndMcpAccessTest.php @@ -1,6 +1,7 @@ and($firstLimits[1]->key)->toBe($secondLimits[1]->key); }); -it('keeps MCP independent from the REST API access check', function () { +it('keeps MCP independent from the REST API switch but applies the IP allow-list', function () { $mcp = Route::getRoutes()->match(Request::create('/mcp', 'POST')); $enable = Route::getRoutes()->match(Request::create('/api/v1/mcp/enable', 'POST')); $disable = Route::getRoutes()->match(Request::create('/api/v1/mcp/disable', 'POST')); expect($mcp->gatherMiddleware())->not->toContain(ApiAllowed::class) + ->and($mcp->gatherMiddleware())->toContain(ApiIpAllowed::class) ->and($enable->gatherMiddleware())->not->toContain(ApiAllowed::class) ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class) - ->and($enable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', 'api.ability:write') - ->and($disable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', 'api.ability:write'); + ->and($enable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write') + ->and($disable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write'); }); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index f09caca03d..f514caea93 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -117,15 +117,30 @@ test('MCP endpoint rejects unauthenticated requests', function () { $response->assertStatus(401); }); -test('MCP endpoint works when the REST API is disabled and its IP allow-list excludes the client', function () { - InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); +test('MCP endpoint works when the REST API is disabled', function () { + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false]); + Once::flush(); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + mcpListTools($token)->assertOk(); +}); + +test('MCP endpoint rejects a token from an IP outside the allow-list', function () { + InstanceSettings::query()->where('id', 0)->update(['allowed_ips' => '192.0.2.10']); + Once::flush(); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + mcpListTools($token) + ->assertForbidden() + ->assertJson(['message' => 'You are not allowed to access the API.']); +}); + +test('MCP endpoint accepts a token from an IP inside the allow-list', function () { + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10, 127.0.0.0/8']); Once::flush(); $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; mcpListTools($token)->assertOk(); - test()->withHeader('Authorization', 'Bearer '.$token) - ->getJson('/api/v1/version') - ->assertForbidden(); }); test('MCP endpoint lists tools for an authenticated token', function () { diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php index 0de306cd01..9759668262 100644 --- a/tests/Feature/Mcp/McpToggleApiTest.php +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -94,7 +94,7 @@ test('non-root token cannot disable MCP server', function () { }); test('root token can enable MCP server when the REST API is disabled', function () { - InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false]); $token = makeRootMcpToken($this->user); test()->withHeaders(['Authorization' => 'Bearer '.$token]) @@ -104,6 +104,17 @@ test('root token can enable MCP server when the REST API is disabled', function expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); +test('root token cannot enable MCP server from an IP outside the allow-list', function () { + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); + $token = makeRootMcpToken($this->user); + + test()->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson('/api/v1/mcp/enable') + ->assertForbidden(); + + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); +}); + test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { $response = test()->postJson('/api/v1/mcp/enable'); $response->assertStatus(401);