set('app.maintenance.store', 'array'); config()->set('constants.coolify.self_hosted', true); InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'allowed_ips' => '203.0.113.50,2001:db8::50']); $team = Team::factory()->create(); $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); $this->apiToken = $user->createToken('client-ip', ['read'], teamId: $team->id)->plainTextToken; }); /** * An API call with a valid token: 403 when the allowed IPs block the client, 200 when they let it through. * * @param array $headers */ function apiIpCheckFromPeer(string $peer, array $headers = []): TestResponse { return test() ->withToken(test()->apiToken) ->withHeaders($headers) ->withServerVariables(['REMOTE_ADDR' => $peer]) ->getJson('/api/v1/version'); } it('ignores forwarded headers from a public peer for the allowed IPs', function (array $headers) { apiIpCheckFromPeer('198.51.100.7', $headers)->assertForbidden(); })->with([ 'X-Forwarded-For' => [['X-Forwarded-For' => '203.0.113.50']], 'fake Cloudflare hop' => [['X-Forwarded-For' => '104.16.0.1', 'CF-Connecting-IP' => '203.0.113.50']], ]); it('ignores forwarded headers from a public IPv6 peer for the allowed IPs', function (string $peer) { apiIpCheckFromPeer($peer, ['X-Forwarded-For' => '2001:db8::50'])->assertForbidden(); })->with(['2001:db8:ffff::7', '::ffff:198.51.100.7']); it('uses the forwarded client from a local reverse proxy for the allowed IPs', function (string $peer, string $client) { apiIpCheckFromPeer($peer, ['X-Forwarded-For' => $client])->assertOk(); apiIpCheckFromPeer($peer, ['X-Forwarded-For' => '198.51.100.7'])->assertForbidden(); })->with([ 'Docker network' => ['10.0.1.5', '203.0.113.50'], 'Docker bridge' => ['172.18.0.2', '203.0.113.50'], 'loopback' => ['127.0.0.1', '203.0.113.50'], 'IPv6 loopback' => ['::1', '2001:db8::50'], 'IPv6 unique local' => ['fd00::2', '2001:db8::50'], 'IPv6 link local' => ['fe80::2', '2001:db8::50'], 'IPv4-mapped private' => ['::ffff:10.0.1.5', '203.0.113.50'], ]); it('uses the Cloudflare client IP behind a local reverse proxy only when the proxy received the request from Cloudflare', function () { apiIpCheckFromPeer('10.0.1.5', [ 'X-Forwarded-For' => '203.0.113.50, 162.158.79.140', 'CF-Connecting-IP' => '203.0.113.50', ])->assertOk(); apiIpCheckFromPeer('10.0.1.5', [ 'X-Forwarded-For' => '104.16.0.1, 198.51.100.7', 'CF-Connecting-IP' => '203.0.113.50', ])->assertForbidden(); }); it('uses the Cloudflare client IP when Cloudflare connects directly', function () { apiIpCheckFromPeer('162.158.79.140', ['CF-Connecting-IP' => '203.0.113.50'])->assertOk(); }); it('keys the API rate limit by the public peer, not by forwarded headers', function () { config()->set('api.rate_limit', 2); InstanceSettings::query()->update(['allowed_ips' => null]); $push = fn (string $peer, array $headers) => test() ->withHeaders($headers) ->withServerVariables(['REMOTE_ADDR' => $peer]) ->postJson('/api/v1/sentinel/push'); $push('198.51.100.7', ['X-Forwarded-For' => '203.0.113.1'])->assertUnauthorized(); $push('198.51.100.7', ['X-Forwarded-For' => '203.0.113.2', 'CF-Connecting-IP' => '203.0.113.2'])->assertUnauthorized(); $push('198.51.100.7', ['X-Forwarded-For' => '104.16.0.1', 'CF-Connecting-IP' => '203.0.113.3'])->assertStatus(429); $push('10.0.1.5', ['X-Forwarded-For' => '203.0.113.10'])->assertUnauthorized(); $push('10.0.1.5', ['X-Forwarded-For' => '203.0.113.20'])->assertUnauthorized(); }); it('ignores X-Forwarded-For from a public peer on cloud', function () { config()->set('constants.coolify.self_hosted', false); config()->set('api.rate_limit', 2); $push = fn (string $forwardedFor) => test() ->withHeader('X-Forwarded-For', $forwardedFor) ->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) ->postJson('/api/v1/sentinel/push'); $push('203.0.113.1')->assertUnauthorized(); $push('203.0.113.2')->assertUnauthorized(); $push('203.0.113.3')->assertStatus(429); });