mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
1 parent
31cb4c9039
commit
f5ead3ed43
9 files changed
+82
-33
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ApiIpAllowed
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (isCloud()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$allowedIps = trim((string) instanceSettings()->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);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,8 @@
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input id="allowed_ips" label="Allowed API IPs"
|
||||
helper="Comma-separated IPs or CIDR ranges. Empty or 0.0.0.0 allows all sources."
|
||||
<x-forms.input id="allowed_ips" label="Allowed IPs"
|
||||
helper="Applies to the REST API and the MCP endpoint. Comma-separated IPs or CIDR ranges. Empty or 0.0.0.0 allows all sources."
|
||||
placeholder="192.168.1.100, 10.0.0.0/8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ApiIpAllowed;
|
||||
use App\Mcp\Servers\CoolifyServer;
|
||||
use Laravel\Mcp\Facades\Mcp;
|
||||
|
||||
Mcp::web('/mcp', CoolifyServer::class)
|
||||
->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']);
|
||||
->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'mcp.team.enabled']);
|
||||
+2
-1
@@ -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']);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ApiAllowed;
|
||||
use App\Http\Middleware\ApiIpAllowed;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -18,14 +19,15 @@ it('limits login attempts by normalized email independent of IP', function () {
|
||||
->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');
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user