fix(api): use the Cloudflare client IP for allowed IPs and audit logs

Closes #12301

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-25 23:47:39 +01:00
co-authored by Claude Opus 5.5
parent 0f0a1e920d
commit 80ce0efe7e
12 changed files with 77 additions and 23 deletions
+12
View File
@@ -35,6 +35,7 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
@@ -170,6 +171,17 @@ test('audit log persists a structured event for the current team', function () {
->and($event->metadata['changed'])->toBe(['name']);
});
test('audit log records the Cloudflare client ip when the request comes from Cloudflare', function () {
app()->instance('request', Request::create('/', server: [
'REMOTE_ADDR' => '162.158.79.140',
'HTTP_CF_CONNECTING_IP' => '160.79.104.10',
]));
auditLog('api.application.updated', ['application_uuid' => 'app-123']);
expect(AuditEvent::query()->sole()->ip_address)->toBe('160.79.104.10');
});
test('auditable models record authenticated create update and delete actions', function () {
$project = Project::factory()->create([
'team_id' => $this->team->id,
+15 -4
View File
@@ -10,7 +10,7 @@ it('uses the Cloudflare client ip on cloud', function () {
'HTTP_CF_CONNECTING_IP' => '2001:db8::10',
]);
expect(auth_rate_limit_ip($request))->toBe('2001:db8::10');
expect(client_ip($request))->toBe('2001:db8::10');
});
it('falls back to the resolved request ip when the Cloudflare header is invalid', function () {
@@ -21,10 +21,21 @@ it('falls back to the resolved request ip when the Cloudflare header is invalid'
'HTTP_CF_CONNECTING_IP' => 'invalid',
]);
expect(auth_rate_limit_ip($request))->toBe('203.0.113.10');
expect(client_ip($request))->toBe('203.0.113.10');
});
it('ignores the Cloudflare header on self-hosted instances', function () {
it('uses the Cloudflare client ip on self-hosted instances when the request comes from Cloudflare', function (string $cloudflareEdge) {
config()->set('constants.coolify.self_hosted', true);
$request = Request::create('/', server: [
'REMOTE_ADDR' => $cloudflareEdge,
'HTTP_CF_CONNECTING_IP' => '2001:db8::20',
]);
expect(client_ip($request))->toBe('2001:db8::20');
})->with(['162.158.79.140', '104.24.0.1', '2a06:98c0::1']);
it('ignores the Cloudflare header on self-hosted instances when the request does not come from Cloudflare', function () {
config()->set('constants.coolify.self_hosted', true);
$request = Request::create('/', server: [
@@ -32,5 +43,5 @@ it('ignores the Cloudflare header on self-hosted instances', function () {
'HTTP_CF_CONNECTING_IP' => '2001:db8::20',
]);
expect(auth_rate_limit_ip($request))->toBe('203.0.113.20');
expect(client_ip($request))->toBe('203.0.113.20');
});
+24
View File
@@ -143,6 +143,30 @@ test('MCP endpoint accepts a token from an IP inside the allow-list', function (
mcpListTools($token)->assertOk();
});
test('MCP endpoint checks the allow-list against the client IP behind Cloudflare', function () {
InstanceSettings::query()->where('id', 0)->update(['allowed_ips' => '160.79.104.0/21']);
Once::flush();
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
test()->withServerVariables(['REMOTE_ADDR' => '10.0.1.5'])
->withHeaders(['X-Forwarded-For' => '172.71.194.182', 'CF-Connecting-IP' => '160.79.104.10']);
mcpListTools($token)->assertOk();
});
test('MCP endpoint ignores a CF-Connecting-IP header that does not come from Cloudflare', function () {
InstanceSettings::query()->where('id', 0)->update(['allowed_ips' => '160.79.104.0/21']);
Once::flush();
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
test()->withServerVariables(['REMOTE_ADDR' => '10.0.1.5'])
->withHeaders(['X-Forwarded-For' => '198.51.100.7', 'CF-Connecting-IP' => '160.79.104.10']);
mcpListTools($token)
->assertForbidden()
->assertJson(['message' => 'You are not allowed to access the API.']);
});
test('MCP endpoint lists tools for an authenticated token', function () {
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;