mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 22:15:35 +00:00
Merge remote-tracking branch 'origin/next' into improve-s3-storage-handling
This commit is contained in:
commit
20b5b90cf9
49 files changed
+1979
-257
No files matched your search
@@ -141,3 +141,77 @@ test('file storage accepts relative dot-prefixed paths', function () {
|
||||
expect(fn () => validateShellSafePath('./data', 'storage path'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('file mount path validator rejects parent segments and unsafe separators', function (string $path) {
|
||||
expect(fn () => validateFileMountPath($path, 'file storage path'))
|
||||
->toThrow(Exception::class);
|
||||
})->with([
|
||||
'parent segment to etc' => ['/../../etc/passwd'],
|
||||
'embedded parent segment' => ['/foo/../bar'],
|
||||
'parent segment' => ['/..'],
|
||||
'double slash before parent segment' => ['/foo//../bar'],
|
||||
'current directory segment' => ['/foo/./bar'],
|
||||
'backslash parent segment' => ['\\..\\etc\\passwd'],
|
||||
'null byte' => ["/app/config\0/../../etc/passwd"],
|
||||
]);
|
||||
|
||||
test('file mount path validator accepts safe absolute container file paths', function (string $path, string $expected) {
|
||||
expect(validateFileMountPath($path, 'file storage path'))->toBe($expected);
|
||||
})->with([
|
||||
'nginx config' => ['/etc/nginx/nginx.conf', '/etc/nginx/nginx.conf'],
|
||||
'app env filename' => ['/app/.env', '/app/.env'],
|
||||
'relative input becomes absolute' => ['config/app.yaml', '/config/app.yaml'],
|
||||
'duplicate slashes collapse' => ['/opt//app///config.json', '/opt/app/config.json'],
|
||||
]);
|
||||
|
||||
test('host file mount path validator accepts absolute host file paths', function () {
|
||||
expect(validateHostFileMountPath('/etc/nginx/nginx.conf', 'host file path'))
|
||||
->toBe('/etc/nginx/nginx.conf');
|
||||
});
|
||||
|
||||
test('host file mount path validator rejects ambiguous or directory paths', function (string $path) {
|
||||
expect(fn () => validateHostFileMountPath($path, 'host file path'))
|
||||
->toThrow(Exception::class);
|
||||
})->with([
|
||||
'relative path' => ['etc/nginx/nginx.conf'],
|
||||
'root directory' => ['/'],
|
||||
'trailing slash' => ['/etc/nginx/'],
|
||||
'parent segment' => ['/etc/../shadow'],
|
||||
'current segment' => ['/etc/./nginx.conf'],
|
||||
'backslash' => ['\\etc\\nginx.conf'],
|
||||
]);
|
||||
|
||||
test('confined path resolver keeps file mounts inside their resource configuration root', function () {
|
||||
expect(confineFileMountPath('/data/coolify/applications/app-uuid', '/etc/nginx/nginx.conf', 'file storage path'))
|
||||
->toBe('/data/coolify/applications/app-uuid/etc/nginx/nginx.conf');
|
||||
|
||||
expect(confineFileMountPath('/data/coolify/databases/db-uuid/', 'postgres/postgresql.conf', 'file storage path'))
|
||||
->toBe('/data/coolify/databases/db-uuid/postgres/postgresql.conf');
|
||||
|
||||
expect(confineFileMountPath('/data/coolify/services/service-uuid', '/config.yaml', 'file storage path'))
|
||||
->toBe('/data/coolify/services/service-uuid/config.yaml');
|
||||
});
|
||||
|
||||
test('confined path resolver rejects paths that escape the resource configuration root', function (string $base, string $path) {
|
||||
expect(fn () => confineFileMountPath($base, $path, 'file storage path'))
|
||||
->toThrow(Exception::class);
|
||||
})->with([
|
||||
'application parent segment' => ['/data/coolify/applications/app-uuid', '/../../etc/passwd'],
|
||||
'database parent segment' => ['/data/coolify/databases/db-uuid', '/postgres/../../../etc/shadow'],
|
||||
'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
|
||||
]);
|
||||
|
||||
test('local file volume write sink keeps saved managed file paths for compatibility', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
|
||||
|
||||
expect($source)->not->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
|
||||
->and($source)->toContain('tee {$escapedPath}');
|
||||
});
|
||||
|
||||
test('host file mounts are bind-only and skipped by server storage writes', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
|
||||
|
||||
expect($source)->toContain('if ($this->is_host_file) {')
|
||||
->and($source)->toContain('return;')
|
||||
->and($source)->toContain('tee {$escapedPath}');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\SendMessageToDiscordJob;
|
||||
use App\Jobs\SendMessageToSlackJob;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('blocks queued Slack notifications to IPv4-mapped link-local URLs', function () {
|
||||
Http::fake();
|
||||
|
||||
$job = new SendMessageToSlackJob(
|
||||
new SlackMessage('Test', 'Description'),
|
||||
'http://[::ffff:169.254.169.254]/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks queued Discord notifications to IPv4-mapped link-local URLs', function () {
|
||||
Http::fake();
|
||||
|
||||
$job = new SendMessageToDiscordJob(
|
||||
new DiscordMessage('Test', 'Description', DiscordMessage::infoColor()),
|
||||
'http://[::ffff:169.254.169.254]/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Policies\TeamPolicy;
|
||||
|
||||
function teamPolicyUserWithTeams(array $teamIds): User
|
||||
{
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn(collect(
|
||||
array_map(fn (int $teamId): object => (object) ['id' => $teamId], $teamIds)
|
||||
));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function teamPolicyTeam(int $teamId): Team
|
||||
{
|
||||
$team = Mockery::mock(Team::class)->makePartial();
|
||||
$team->shouldReceive('getAttribute')->with('id')->andReturn($teamId);
|
||||
|
||||
return $team;
|
||||
}
|
||||
|
||||
it('allows any authenticated user to view any teams list', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
expect((new TeamPolicy)->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows authenticated users to create teams', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
expect((new TeamPolicy)->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows target team members to view the team', function () {
|
||||
$user = teamPolicyUserWithTeams([1]);
|
||||
$team = teamPolicyTeam(1);
|
||||
|
||||
expect((new TeamPolicy)->view($user, $team))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-members from viewing the team', function () {
|
||||
$user = teamPolicyUserWithTeams([2]);
|
||||
$team = teamPolicyTeam(1);
|
||||
|
||||
expect((new TeamPolicy)->view($user, $team))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows target team admins to perform privileged team actions', function (string $ability) {
|
||||
$user = teamPolicyUserWithTeams([1]);
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
$team = teamPolicyTeam(1);
|
||||
|
||||
expect((new TeamPolicy)->{$ability}($user, $team))->toBeTrue();
|
||||
})->with([
|
||||
'update',
|
||||
'delete',
|
||||
'manageMembers',
|
||||
'viewAdmin',
|
||||
'manageInvitations',
|
||||
]);
|
||||
|
||||
it('denies target team members even when their current session role is admin elsewhere', function (string $ability) {
|
||||
$user = teamPolicyUserWithTeams([1, 2]);
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
$user->shouldReceive('isOwner')->andReturn(false);
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
$team = teamPolicyTeam(1);
|
||||
|
||||
expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
|
||||
})->with([
|
||||
'update',
|
||||
'delete',
|
||||
'manageMembers',
|
||||
'viewAdmin',
|
||||
'manageInvitations',
|
||||
]);
|
||||
|
||||
it('denies non-members from privileged team actions', function (string $ability) {
|
||||
$user = teamPolicyUserWithTeams([2]);
|
||||
$team = teamPolicyTeam(1);
|
||||
|
||||
expect((new TeamPolicy)->{$ability}($user, $team))->toBeFalse();
|
||||
})->with([
|
||||
'update',
|
||||
'delete',
|
||||
'manageMembers',
|
||||
'viewAdmin',
|
||||
'manageInvitations',
|
||||
]);
|
||||
@@ -23,8 +23,9 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
|
||||
})->with([
|
||||
'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
|
||||
'AWS IMDS bare' => 'http://169.254.169.254',
|
||||
'link-local address' => 'http://169.254.169.254/',
|
||||
'link-local address bare' => 'http://169.254.169.254',
|
||||
'link-local address IPv4-mapped IPv6' => 'http://[::ffff:169.254.169.254]/',
|
||||
'GCP metadata via link-local' => 'http://169.254.0.1',
|
||||
'loopback v4' => 'http://127.0.0.1',
|
||||
'loopback Redis' => 'http://127.0.0.1:6379',
|
||||
@@ -87,5 +88,6 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
|
||||
'http loopback' => 'http://127.0.0.1:6379',
|
||||
'localhost' => 'http://localhost:9000',
|
||||
'IPv6 loopback' => 'http://[::1]',
|
||||
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
|
||||
'internal TLD' => 'http://backend.internal',
|
||||
]);
|
||||
@@ -75,6 +75,7 @@ test('S3Storage connection validation uses short s3 client timeouts', function (
|
||||
->with(Mockery::on(function (array $config) {
|
||||
expect($config['http']['connect_timeout'])->toBe(15);
|
||||
expect($config['http']['timeout'])->toBe(15);
|
||||
expect($config['http']['allow_redirects'])->toBeFalse();
|
||||
|
||||
return true;
|
||||
}))
|
||||
|
||||
@@ -11,7 +11,7 @@ it('accepts valid public URLs', function () {
|
||||
|
||||
$validUrls = [
|
||||
'https://api.github.com',
|
||||
'https://github.example.com/api/v3',
|
||||
'https://github.com/api/v3',
|
||||
'https://example.com',
|
||||
'http://example.com',
|
||||
];
|
||||
@@ -22,6 +22,14 @@ it('accepts valid public URLs', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom external hostnames that resolve to public IPs', function () {
|
||||
$rule = new SafeExternalUrl(fn (string $host): array => ['93.184.216.34']);
|
||||
|
||||
$validator = Validator::make(['url' => 'https://github.example.com/api/v3'], ['url' => $rule]);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected valid custom external hostname');
|
||||
});
|
||||
|
||||
it('rejects private IPv4 addresses', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
@@ -42,6 +50,34 @@ it('rejects cloud metadata IP', function () {
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
|
||||
});
|
||||
|
||||
it('rejects hostnames that resolve to private or reserved addresses', function (string $url, array $resolvedIps) {
|
||||
$rule = new SafeExternalUrl(fn (string $host): array => $resolvedIps);
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
|
||||
})->with([
|
||||
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
|
||||
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
|
||||
'hostname to private IPv4' => ['http://private.example.test/', ['10.0.0.1']],
|
||||
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
|
||||
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
|
||||
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
|
||||
'hostname to mapped private IPv4' => ['http://mapped-private.example.test/', ['::ffff:10.0.0.1']],
|
||||
]);
|
||||
|
||||
it('rejects IPv4-mapped IPv6 literals for private or reserved IPv4 ranges', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
|
||||
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
|
||||
'mapped private' => 'http://[::ffff:10.0.0.1]/',
|
||||
]);
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
@@ -50,9 +86,12 @@ it('rejects localhost and internal hostnames', function (string $url) {
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'localhost with trailing dot' => 'http://localhost.',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
'.local domain' => 'http://myservice.local',
|
||||
'.local domain with trailing dot' => 'http://myservice.local.',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
'.internal domain with trailing dot' => 'http://myservice.internal.',
|
||||
]);
|
||||
|
||||
it('rejects non-URL strings', function (string $value) {
|
||||
|
||||
@@ -59,6 +59,33 @@ it('rejects link-local range', function () {
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
|
||||
});
|
||||
|
||||
it('rejects hostnames that resolve to blocked addresses', function (string $url, array $resolvedIps) {
|
||||
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection after DNS resolution: {$url}");
|
||||
})->with([
|
||||
'hostname to link-local IP' => ['http://169.254.169.254.nip.io/', ['169.254.169.254']],
|
||||
'hostname to loopback' => ['http://loopback.example.test/', ['127.0.0.1']],
|
||||
'hostname to IPv6 loopback' => ['http://ipv6-loopback.example.test/', ['::1']],
|
||||
'hostname to IPv6 link-local' => ['http://ipv6-link-local.example.test/', ['fe80::1']],
|
||||
'hostname to IPv6 ULA' => ['http://ipv6-ula.example.test/', ['fc00::1']],
|
||||
'hostname to mapped link-local IP' => ['http://mapped-link-local.example.test/', ['::ffff:169.254.169.254']],
|
||||
]);
|
||||
|
||||
it('rejects IPv4-mapped IPv6 literals for blocked IPv4 ranges', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'mapped link-local IP' => 'http://[::ffff:169.254.169.254]/',
|
||||
'mapped loopback' => 'http://[::ffff:127.0.0.1]/',
|
||||
'mapped zero' => 'http://[::ffff:0.0.0.0]/',
|
||||
]);
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
@@ -67,7 +94,9 @@ it('rejects localhost and internal hostnames', function (string $url) {
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'localhost with trailing dot' => 'http://localhost.',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
'.internal domain with trailing dot' => 'http://myservice.internal.',
|
||||
]);
|
||||
|
||||
it('rejects non-http schemes', function (string $value) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Jobs\SendWebhookJob;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -24,11 +23,6 @@ it('sends webhook to valid URLs', function () {
|
||||
|
||||
it('blocks webhook to loopback address', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
@@ -42,15 +36,23 @@ it('blocks webhook to loopback address', function () {
|
||||
|
||||
it('blocks webhook to cloud metadata endpoint', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://169.254.169.254/latest/meta-data/'
|
||||
webhookUrl: 'http://169.254.169.254/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks webhook to IPv4-mapped IPv6 link-local endpoint', function () {
|
||||
Http::fake();
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://[::ffff:169.254.169.254]/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
@@ -60,11 +62,6 @@ it('blocks webhook to cloud metadata endpoint', function () {
|
||||
|
||||
it('blocks webhook to localhost', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
|
||||
Reference in new issue
Block a user