Merge remote-tracking branch 'origin/next' into improve-application-url-handling

This commit is contained in:
Andras Bacsai committed 2026-07-02 16:57:14 +02:00
commit ed6352682e
18 files changed
+1340 -338

No files matched your search

@@ -0,0 +1,121 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
function setDeploymentJobProperty(object $job, string $property, mixed $value): void
{
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
$reflectionProperty->setValue($job, $value);
}
function getDeploymentJobProperty(object $job, string $property): mixed
{
$reflectionProperty = new ReflectionProperty(ApplicationDeploymentJob::class, $property);
return $reflectionProperty->getValue($job);
}
function invokeCheckGitIfBuildNeeded(object $job): void
{
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'check_git_if_build_needed');
$method->invoke($job);
}
function makeDeploymentJobForCommitCheck(string $pinnedSha, string $branchHeadSha): object
{
$job = new class extends ApplicationDeploymentJob
{
public function __construct() {}
public function execute_remote_command(...$commands) {}
};
$application = new class extends Application
{
public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null)
{
return [
'commands' => collect([]),
'branch' => 'main',
'fullRepoUrl' => 'https://github.com/coollabsio/coolify.git',
];
}
};
$application->forceFill([
'uuid' => 'application-uuid',
'git_branch' => 'main',
]);
$application->setRelation('settings', (object) [
'include_source_commit_in_build' => false,
'use_build_secrets' => false,
]);
$application->setRelation('private_key', null);
$deploymentQueue = new class extends ApplicationDeploymentQueue
{
public bool $saved = false;
public function save(array $options = []): bool
{
$this->saved = true;
return true;
}
};
$deploymentQueue->commit = $pinnedSha;
setDeploymentJobProperty($job, 'application', $application);
setDeploymentJobProperty($job, 'application_deployment_queue', $deploymentQueue);
setDeploymentJobProperty($job, 'deployment_uuid', 'deployment-uuid');
setDeploymentJobProperty($job, 'pull_request_id', 0);
setDeploymentJobProperty($job, 'commit', $pinnedSha);
setDeploymentJobProperty($job, 'rollback', false);
setDeploymentJobProperty($job, 'git_type', 'github');
setDeploymentJobProperty($job, 'saved_outputs', collect([
'git_commit_sha' => str("{$branchHeadSha}\trefs/heads/main"),
]));
return $job;
}
function shouldResolveBranchHeadForCommit(?string $commit): bool
{
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$commitProperty = new ReflectionProperty($job, 'commit');
$commitProperty->setValue($job, $commit ?? '');
$method = new ReflectionMethod($job, 'shouldResolveBranchHeadCommit');
return $method->invoke($job);
}
describe('ApplicationDeploymentJob commit resolution', function () {
test('resolves branch head for HEAD deployments', function () {
expect(shouldResolveBranchHeadForCommit('HEAD'))->toBeTrue();
});
test('resolves branch head for blank deployments', function () {
expect(shouldResolveBranchHeadForCommit(''))->toBeTrue();
});
test('keeps pinned deployment commits instead of replacing them with branch head', function () {
expect(shouldResolveBranchHeadForCommit('abc123def456abc123def456abc123def456abc1'))->toBeFalse();
});
test('check git does not overwrite pinned deployment commit with branch head', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$branchHeadSha = '111222333444555666777888999000aaabbbccc1';
$job = makeDeploymentJobForCommitCheck($pinnedSha, $branchHeadSha);
invokeCheckGitIfBuildNeeded($job);
$deploymentQueue = getDeploymentJobProperty($job, 'application_deployment_queue');
expect(getDeploymentJobProperty($job, 'commit'))->toBe($pinnedSha)
->and($deploymentQueue->commit)->toBe($pinnedSha)
->and($deploymentQueue->saved)->toBeFalse();
});
});
+22 -9
View File
@@ -1,21 +1,23 @@
<?php
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
/**
* Regression tests for SSRF via S3 Storage endpoint.
* Regression tests for S3 Storage endpoint validation.
*
* The Livewire forms (Create.php, Form.php) and the model-level defense in
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
* assert the rule rejects the concrete payloads and that the model refuses to
* build an S3 client for an unsafe endpoint.
*/
it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
it('rejects disallowed targets on the S3 endpoint', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
@@ -43,19 +45,15 @@ it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
it('accepts real-world S3 endpoints', function (string $endpoint) {
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34'])]],
);
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
})->with([
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
'Custom domain MinIO' => 'https://minio.example.com',
'Custom public domain S3-compatible endpoint' => 'https://example.com',
]);
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
@@ -91,3 +89,18 @@ it('blocks testConnection() for loopback endpoints', function (string $endpoint)
'IPv4-mapped IPv6 link-local' => 'http://[::ffff:169.254.169.254]',
'internal TLD' => 'http://backend.internal',
]);
it('accepts explicitly allowlisted intranet S3 endpoints', function (string $endpoint, array $allowlist) {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
$validator = Validator::make(
['endpoint' => $endpoint],
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
);
expect($validator->passes())->toBeTrue("Expected allowlisted intranet S3 endpoint: {$endpoint}");
})->with([
'Self-hosted MinIO on 10.x CIDR' => ['http://10.0.0.5:9000', ['10.0.0.0/8']],
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
]);
+174 -13
View File
@@ -1,13 +1,15 @@
<?php
use App\Models\InstanceSettings;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
uses(TestCase::class, RefreshDatabase::class);
it('accepts valid public URLs', function () {
$rule = new SafeWebhookUrl;
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validUrls = [
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
@@ -22,17 +24,6 @@ it('accepts valid public URLs', function () {
}
});
it('accepts private network IPs for self-hosted deployments', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
})->with([
'10.x range' => 'http://10.0.0.5/webhook',
'172.16.x range' => 'http://172.16.0.1:8080/hook',
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
]);
it('rejects loopback addresses', function (string $url) {
$rule = new SafeWebhookUrl;
@@ -117,3 +108,173 @@ it('rejects IPv6 loopback', function () {
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
});
it('rejects private and reserved network targets by default', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
})->with([
'private 10/8' => 'http://10.0.0.5/webhook',
'private 172.16/12' => 'http://172.16.0.1:8080/hook',
'private 192.168/16' => 'http://192.168.1.50:8080/webhook',
'shared address space' => 'http://100.64.0.1/webhook',
'zero network peer alias' => 'http://0.0.0.1/webhook',
'multicast' => 'http://224.0.0.1/webhook',
'benchmark range' => 'http://198.18.0.1/webhook',
'documentation range' => 'http://192.0.2.10/webhook',
]);
it('rejects hostname forms that resolve to loopback', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected loopback hostname-form rejection: {$url}");
})->with([
'decimal IPv4' => 'http://2130706433:8888/exfil',
'hex IPv4' => 'http://0x7f000001:8888/exfil',
'octal IPv4' => 'http://017700000001:8888/exfil',
'short dotted IPv4' => 'http://127.1:8888/exfil',
'IPv4-mapped IPv6 hex loopback' => 'http://[::ffff:7f00:1]:8888/exfil',
]);
it('rejects internal DNS suffixes by default', function (string $url) {
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected default rejection: {$url}");
})->with([
'.local host' => 'http://receiver.local/webhook',
'.cluster.local host' => 'http://service.cluster.local/webhook',
]);
it('rejects unresolvable hostnames by default', function () {
$rule = new SafeWebhookUrl(fn (string $host): array => []);
$validator = Validator::make(['url' => 'http://does-not-resolve.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected default rejection for unresolvable host');
});
it('keeps webhook DNS resolution enabled when general DNS validation is disabled', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['is_dns_validation_enabled' => false]));
$rule = new SafeWebhookUrl(fn (string $host): array => ['127.0.0.1']);
$validator = Validator::make(['url' => 'http://rebinding.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected webhook SSRF DNS checks to remain enabled');
});
it('reads configured custom DNS servers for webhook hostname resolution', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, invalid, 2606:4700:4700::1111']));
$method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers');
$method->setAccessible(true);
expect($method->invoke(new SafeWebhookUrl))
->toBe(['1.1.1.1', '2606:4700:4700::1111']);
});
it('allows explicitly configured intranet webhook targets', function (string $url, array $resolvedIps, array $allowlist) {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => $allowlist]));
$rule = new SafeWebhookUrl(fn (string $host): array => $resolvedIps);
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Expected configured intranet target to pass: {$url}");
})->with([
'exact .local hostname' => ['http://receiver.local/webhook', ['192.168.10.20'], ['receiver.local']],
'private CIDR' => ['http://hooks.example.test/webhook', ['10.50.10.20'], ['10.50.0.0/16']],
]);
it('requires explicit localhost opt in in addition to allowlist', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allowed_internal_hosts' => ['localhost']]));
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected localhost to remain blocked without explicit localhost opt in');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['webhook_allow_localhost' => true]));
$validator = Validator::make(['url' => 'http://localhost:8080/webhook'], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Expected localhost to pass only after explicit localhost opt in');
});
it('builds HTTP client options that pin resolved DNS for the request', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
$options = SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook');
expect($options['allow_redirects'])->toBeFalse();
if (defined('CURLOPT_RESOLVE')) {
expect($options['curl'][CURLOPT_RESOLVE])->toContain('localhost:8080:127.0.0.1');
}
});
it('fails closed while building HTTP options when the send-time resolution is unsafe', function () {
expect(fn () => SafeWebhookUrl::httpClientOptions('http://localhost:8080/webhook'))
->toThrow(RuntimeException::class, 'unsafe IP address');
});
it('builds MinIO client resolve options for S3 backup uploads', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
expect($options)->toContain('localhost:9000=127.0.0.1');
});
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
$rule = new SafeWebhookUrl(fn (string $host): array => ['93.184.216.34']);
$validator = Validator::make(['url' => 'http://example.com./webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue('Expected trailing-dot hostname rejection');
expect(fn () => SafeWebhookUrl::httpClientOptions('http://example.com./webhook'))
->toThrow(RuntimeException::class, 'trailing dot');
});
it('rejects reserved IPv6 ranges by default', function (string $url) {
$rule = new SafeWebhookUrl;
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected reserved IPv6 rejection: {$url}");
})->with([
'documentation IPv6' => 'http://[2001:db8::1]/webhook',
'IPv4/IPv6 translation prefix' => 'http://[64:ff9b::1]/webhook',
'6to4' => 'http://[2002::1]/webhook',
]);
it('rejects hostnames that resolve to reserved IPv6 ranges by default', function (string $resolvedIp) {
$rule = new SafeWebhookUrl(fn (string $host): array => [$resolvedIp]);
$validator = Validator::make(['url' => 'http://ipv6-reserved.example.test/webhook'], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Expected reserved IPv6 resolution rejection: {$resolvedIp}");
})->with([
'2001:db8::1',
'64:ff9b::1',
'2002::1',
]);
it('redacts webhook URLs for logs', function () {
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
->toBe('https://hooks.slack.com');
});