Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai committed 2026-07-02 15:57:43 +02:00
commit 13172849e1
117 files changed
+5114 -1799

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
<?php
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = destinationsApiToken($this->user, $this->team, ['*']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
});
function destinationsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function destinationsApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'destinations-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
describe('GET /api/v1/destinations', function () {
test('lists only destinations owned by the token team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson('/api/v1/destinations');
$response->assertOk();
$uuids = collect($response->json())->pluck('uuid');
expect($response->json('0'))->not->toHaveKey('id')
->and($uuids)->toContain($this->destination->uuid)
->not->toContain($otherDestination->uuid);
});
});
describe('GET /api/v1/destinations/{uuid}', function () {
test('does not expose another team destination', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson("/api/v1/destinations/{$otherDestination->uuid}");
$response->assertNotFound();
});
});
describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
test('lists destinations for a team server', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$this->server->uuid}/destinations");
$response->assertOk();
expect($response->json())->toHaveCount(1)
->and($response->json('0.uuid'))->toBe($this->destination->uuid);
});
});
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'new-network',
]);
$response->assertForbidden();
});
test('rejects create requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = destinationsApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'member-network',
]);
$response->assertForbidden();
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', 'member-network')->exists())->toBeFalse();
});
test('rejects non-json requests before creating a destination', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->post("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'api-swarm-network',
'type' => 'swarm',
]);
$response->assertStatus(400);
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeFalse();
});
test('rejects unknown fields', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'new-network',
'unexpected' => 'value',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['unexpected']);
});
test('rejects unsafe docker network names', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'bad;network',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['network']);
});
test('rejects a destination type that does not match the server mode', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'wrong-type-network',
'type' => 'swarm',
]);
$response->assertUnprocessable();
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse();
});
test('creates a swarm destination on a swarm server', function () {
$this->server->settings()->update(['is_swarm_manager' => true]);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'name' => 'API Swarm',
'network' => 'api-swarm-network',
'type' => 'swarm',
]);
$response->assertCreated();
$response->assertJsonStructure(['uuid']);
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeTrue();
});
test('rejects duplicate networks on the same server and type', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => $this->destination->network,
]);
$response->assertStatus(409);
});
test('returns conflict when the database unique constraint wins a create race', function () {
$network = 'raced-network';
StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) {
if ($destination->network !== $network) {
return;
}
DB::table('standalone_dockers')->insert([
'name' => 'Concurrent destination',
'uuid' => (string) Str::uuid(),
'network' => $network,
'server_id' => $destination->server_id,
'created_at' => now(),
'updated_at' => now(),
]);
});
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => $network,
]);
$response->assertStatus(409)
->assertJson(['message' => 'A destination with this network already exists on the server.']);
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1);
});
});
describe('DELETE /api/v1/destinations/{uuid}', function () {
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertForbidden();
$this->assertModelExists($this->destination);
});
test('rejects delete requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = destinationsApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertForbidden();
$this->assertModelExists($this->destination);
});
test('deletes standalone destinations after removing the docker network', function () {
$cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
$cleanup->shouldReceive('handle')
->once()
->with(Mockery::on(fn (StandaloneDocker $destination) => $destination->is($this->destination)));
$this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertOk();
$this->assertModelMissing($this->destination);
});
test('blocks deleting a destination with an attached service', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = $project->environments()->first();
Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertStatus(409);
$this->assertModelExists($this->destination);
});
});
@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -252,3 +252,94 @@ describe('PATCH /api/v1/applications/{uuid}/envs', function () {
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});
describe('environment variable key validation for app and service APIs', function () {
test('rejects invalid service environment variable keys on create update and bulk', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
test('rejects invalid application environment variable keys on create update and bulk', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -18,7 +18,9 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -0,0 +1,89 @@
<?php
use App\Http\Middleware\CanUpdateResource;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
function requestWithCanUpdateResourceRouteParameter(string $parameter, ?string $value): Request
{
$parameters = [
'application_uuid' => null,
'database_uuid' => null,
'stack_service_uuid' => null,
'service_uuid' => null,
'server_uuid' => null,
'environment_uuid' => null,
'project_uuid' => null,
$parameter => $value,
];
$request = Mockery::mock(Request::class)->makePartial();
$request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
return $request;
}
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
it('blocks members from update-only project routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('allows admins through update-only project routes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$response = (new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
expect($response->getContent())->toBe('ok');
});
it('blocks members from update-only server routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('returns not found when an update-only route references an unknown resource', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
fn () => response('ok')
);
})->throws(NotFoundHttpException::class, 'Resource not found.');
@@ -161,6 +161,33 @@ test('member cannot update email notification settings', function () {
->assertForbidden();
});
test('member cannot update smtp email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpPassword', 'member-smtp-password')
->call('submitSmtp')
->assertForbidden();
});
test('member cannot update resend email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('resendApiKey', 'member-resend-api-key')
->call('submitResend')
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
@@ -312,6 +339,10 @@ test('member cannot view notification secrets', function (string $component, str
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
@@ -346,4 +377,8 @@ test('admin can view notification secrets', function (string $component, string
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-admin-password',
'resend_api_key' => 'resend-admin-api-key',
]],
]);
@@ -0,0 +1,133 @@
<?php
use App\Http\Middleware\CanCreateResources;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
InstanceSettings::query()->forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
});
test('member cannot pass create resources middleware', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
expect(fn () => $middleware->handle($request, fn () => response('ok')))
->toThrow(HttpException::class, 'You do not have permission to create resources.');
});
test('admin can pass create resources middleware', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
$response = $middleware->handle($request, fn () => response('ok'));
expect($response->getStatusCode())->toBe(200);
});
test('member cannot create docker compose service through livewire action', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DockerCompose::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('dockerComposeRaw', <<<'YAML'
services:
app:
image: alpine
YAML)
->call('submit')
->assertDispatched('error');
expect(Service::query()->count())->toBe(0);
});
test('public git docker compose creates an application in local mode', function () {
config(['app.env' => 'local']);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PublicGitRepository::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('repository_url', 'https://github.com/coollabsio/coolify')
->set('git_repository', 'https://github.com/coollabsio/coolify')
->set('git_branch', 'main')
->set('build_pack', 'dockercompose')
->set('new_compose_services', true)
->call('submit');
expect(Application::query()->count())->toBe(1)
->and(Service::query()->count())->toBe(0);
});
@@ -126,6 +126,18 @@ test('member cannot submit team settings via policy', function () {
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
test('owner can update team MCP setting', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->set('is_mcp_server_enabled', false)
->call('submit')
->assertDispatched('success');
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
@@ -13,12 +13,19 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Once;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
Once::flush();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
@@ -159,7 +166,8 @@ test('member gets 403 from POST /upload/backup and no file lands on disk', funct
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response = $this->withHeader('Accept', 'application/json')
->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertForbidden();
+124 -2
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
], $overrides));
}
function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team instanceof Team ? $team->id : $team,
]);
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
@@ -60,7 +75,7 @@ beforeEach(function () {
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
createS3StorageForBackupEditValidationTest(Team::factory()->create());
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('defaults to the first available storage when multiple storages are available', function () {
$firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('s3StorageId', $firstS3->id)
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($firstS3->id);
});
it('accepts the S3 storage scope passed to the component', function () {
$s3 = createS3StorageForBackupEditValidationTest(0);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
->set('saveS3', true)
->set('s3StorageId', $s3->id)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('shows available S3 storages even when S3 backup is disabled', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
});
it('shows when S3 backups are currently disabled', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
it('saves selected S3 storage immediately when it changes', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('s3StorageId', $secondS3->id)
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});
@@ -1,6 +1,28 @@
<?php
use App\Http\Controllers\UploadController;
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandalonePostgresql;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
function writeScanPayload(string $content, bool $gzip = false): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scan-payload-');
file_put_contents($path, $gzip ? gzencode($content) : $content);
return $path;
}
/**
* Execute the real shell scanner snippet (as it runs inside the container)
* against a local payload file and return true when the restore is blocked.
*/
function scannerBlocks(string $script): bool
{
return Process::run(['sh', '-c', $script])->exitCode() === 1;
}
function invokeHasAllowedExtension(string $name): bool
{
@@ -10,6 +32,28 @@ function invokeHasAllowedExtension(string $name): bool
return $method->invoke(null, $name);
}
function backupValidationImportFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-');
file_put_contents($path, $content);
return new UploadedFile($path, $name, null, null, true);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
@@ -46,6 +90,140 @@ test('hasAllowedExtension rejects unsupported or empty stems', function (string
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('hasAllowedExtension rejects dangerous double extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php sql' => ['evil.php.sql'],
'php gzip' => ['evil.php.gz'],
'shell tar' => ['evil.sh.tar'],
'php tar gzip' => ['shell.php.tar.gz'],
'exe zip' => ['cmd.exe.zip'],
'jsp sql' => ['evil.jsp.sql'],
]);
test('backup validator rejects content that does not match the backup extension', function () {
$file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip');
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('backup validator accepts valid plain sql and gzip backup content', function () {
$plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n");
$gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n"));
expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue()
->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue();
});
test('postgresql backup safety scanner detects program execution payloads', function (string $payload) {
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue();
})->with([
'copy from program' => ["COPY pwned FROM PROGRAM 'id';"],
'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"],
'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"],
'psql shell command' => ["\\! id\n"],
'psql copy program' => ["\\copy pwned from program 'id'\n"],
]);
test('postgresql backup safety scanner allows ordinary sql dumps', function () {
$dump = <<<'SQL'
-- PostgreSQL database dump
CREATE TABLE users (id integer, name text);
COPY users (id, name) FROM stdin;
1 Taylor
\.
SQL;
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse();
});
test('postgresql restore commands include a safety check before execution', function () {
$component = new class extends ImportForm
{
public function __get($property)
{
if ($property === 'resource') {
return new class
{
public function getMorphClass(): string
{
return StandalonePostgresql::class;
}
};
}
return parent::__get($property);
}
};
$component->container = 'postgres-test';
$command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
->toContain('grep -Eiq');
});
test('non postgresql restore commands do not include a safety check', function () {
$component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
$component->container = 'mysql-test';
expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
$gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue();
});
test('file scanner allows ordinary gzipped dumps', function () {
$gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
});
test('backup validator rejects plaintext .dump containing program execution', function () {
$file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n");
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
'psql shell escape' => ["\\! id\n", false],
'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false],
'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false],
'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false],
'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false],
'psql pipe redirect' => ["\\o | id\n", false],
'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true],
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
'commented out payload' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false],
'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false],
'plain select' => ["SELECT * FROM users;\n", false],
'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -344,3 +344,44 @@ describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
$response->assertStatus(404);
});
});
describe('environment variable key validation for database APIs', function () {
test('rejects invalid database environment variable keys on create update and bulk', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -42,6 +43,7 @@ it('can seed the railpack examples directly on a clean development database', fu
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(GitlabApp::query()->find(1))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -66,9 +68,10 @@ it('seeds the railpack examples in development mode', function () {
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
@@ -79,6 +82,9 @@ it('seeds the railpack examples in development mode', function () {
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
@@ -113,6 +119,33 @@ it('seeds the railpack examples in development mode', function () {
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
expect($githubDeployKey)
->not->toBeNull()
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
->and($githubDeployKey->git_branch)->toBe('main')
->and($githubDeployKey->build_pack)->toBe('railpack')
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($githubDeployKey->source_id)->toBe(0);
expect($gitlabDeployKey)
->not->toBeNull()
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
->and($gitlabDeployKey->git_branch)->toBe('main')
->and($gitlabDeployKey->build_pack)->toBe('railpack')
->and($gitlabDeployKey->private_key_id)->toBe(1)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
->and($gitlabDeployKey->source_id)->toBe(1);
expect($gitlabPublic)
->not->toBeNull()
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
->and($gitlabPublic->base_directory)->toBe('/astro/static')
->and($gitlabPublic->publish_directory)->toBe('/dist')
->and($gitlabPublic->build_pack)->toBe('railpack')
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
->and($gitlabPublic->settings->is_static)->toBeTrue();
});
it('skips the railpack examples outside development mode', function () {
@@ -9,8 +9,8 @@ test('generateDockerBuildArgs returns only keys without values', function () {
$buildArgs = generateDockerBuildArgs($variables);
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
expect($buildArgs->first())->toBe("--build-arg 'SSH_PRIVATE_KEY'");
expect($buildArgs->last())->toBe("--build-arg 'REGULAR_VAR'");
});
test('generateDockerBuildArgs works with collection of objects', function () {
@@ -22,8 +22,8 @@ test('generateDockerBuildArgs works with collection of objects', function () {
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
"--build-arg 'VAR1'",
"--build-arg 'VAR2'",
]);
});
@@ -38,7 +38,7 @@ test('generateDockerBuildArgs collection can be imploded into valid command stri
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
expect($argsString)->toBe("--build-arg 'COOLIFY_URL' --build-arg 'COOLIFY_BRANCH'");
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
@@ -53,7 +53,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
expect($arg)->toBe("--build-arg 'NO_FLAG_VAR'");
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -81,3 +81,17 @@ test('generateDockerEnvFlags works with collection input', function () {
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('generateDockerBuildArgs escapes legacy keys', function () {
$variables = [
['key' => 'BAD$(id)', 'value' => '1'],
['key' => "BAD'KEY", 'value' => '1'],
];
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs->values()->toArray())->toBe([
"--build-arg 'BAD$(id)'",
"--build-arg 'BAD'\''KEY'",
]);
});
+123
View File
@@ -0,0 +1,123 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/../../../../../../etc/example.conf')
->set('file_storage_content', 'owned')
->call('submitFileStorage')
->assertDispatched('error');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
->assertDontSeeText('Actual file mounted from the host system to the container.');
});
test('livewire file storage stores safe file mounts under the application configuration root', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
->and($volume->is_directory)->toBeFalse();
});
test('livewire host file storage stores an existing host file path without managed content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->content)->toBeNull()
->and($volume->is_host_file)->toBeTrue()
->and($volume->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
@@ -262,3 +262,11 @@ test('s3 storage has scheduled backups relationship', function () {
expect($s3->scheduledBackups()->count())->toBe(1);
});
test('database backup job escapes the S3 copy destination argument', function () {
$source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
});
+10
View File
@@ -90,6 +90,16 @@ test('MCP endpoint returns 404 when the instance setting is disabled', function
$response->assertStatus(404);
});
test('MCP endpoint returns 403 when the token team has MCP disabled', function () {
$this->team->update(['is_mcp_server_enabled' => false]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertForbidden();
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
@@ -28,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
+159 -7
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -7,6 +8,8 @@ use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
@@ -18,14 +21,9 @@ use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -87,6 +85,24 @@ function createStorageApiToken($context, array $abilities): string
return $token->getKey().'|'.$plainTextToken;
}
function createTestServiceApplication($context): array
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-app',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return [$service, $serviceApplication];
}
// ──────────────────────────────────────────────────────────────
// Application Storage Endpoints
// ──────────────────────────────────────────────────────────────
@@ -199,6 +215,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse();
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
});
test('creates bind only host file storage for application', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'is_host_file' => true,
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->content)->toBeNull();
expect($vol->is_host_file)->toBeTrue();
expect($vol->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('rejects file storage paths with parent segments', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/../../../../../../etc/example.conf',
'content' => 'owned',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
expect(LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->exists())->toBeFalse();
});
test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () {
@@ -423,6 +489,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra');
});
test('creates a file storage for a database under the database configuration root', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/postgresql.conf',
'content' => 'listen_addresses = "*"',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
});
test('rejects file storage paths with parent segments for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/../../../etc/shadow',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->exists())->toBeFalse();
});
});
describe('POST /api/v1/services/{uuid}/storages', function () {
test('creates a file storage for a service resource under the service configuration root', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/etc/nginx/nginx.conf',
'content' => 'server {}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
});
test('rejects file storage paths with parent segments for a service resource', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->exists())->toBeFalse();
});
});
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
@@ -143,7 +143,51 @@ describe('checkout.session.completed', function () {
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
describe('customer.subscription.updated clamps quantity to subscription bounds', function () {
test('quantity below MIN is clamped to 2', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_existing',
'stripe_customer_id' => 'cus_min_clamp_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_min_clamp_test',
'id' => 'sub_existing',
'status' => 'active',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
'items' => [
'data' => [[
'subscription' => 'sub_existing',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 1,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(2);
Queue::assertPushed(ServerLimitCheckJob::class);
});
test('quantity exceeding MAX is clamped to 100', function () {
Queue::fake();
+33
View File
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
@@ -100,6 +101,38 @@ describe('GitHub Manual Webhook HMAC', function () {
});
});
describe('GitHub App Webhook HMAC', function () {
test('rejects push when app webhook secret is empty', function () {
$team = Team::factory()->create();
GithubApp::create([
'uuid' => (string) str()->uuid(),
'name' => 'github-app-webhook-test',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 1234567890,
'webhook_secret' => null,
'team_id' => $team->id,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['id' => 987654321],
'after' => 'abc123',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('GitLab Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
@@ -3,6 +3,8 @@
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Tests\TestCase;
@@ -236,10 +238,15 @@ it('builds railpack docker command with matching env and secret flags for all ra
],
);
// Build-time variables are interpolated by sourcing the build-time .env file before
// the build, so user/Coolify variables must NOT be forwarded inline as literals.
expect($command)->toContain('set -a && source /artifacts/build-time.env && set +a');
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
// SECRET_JSON is not a buildpack control variable, so it is provided via the sourced
// build-time .env file (which supports $VAR interpolation) rather than inline `env`.
expect($command)->not->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
@@ -247,3 +254,69 @@ it('builds railpack docker command with matching env and secret flags for all ra
expect($command)->toContain(' --build-arg secrets-hash=');
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
});
it('interpolates build-time variable references for railpack by sourcing the build-time env file', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
// Mirrors the issue: BETTER_AUTH_URL=$COOLIFY_URL must be interpolated at build time.
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'BETTER_AUTH_URL' => '$COOLIFY_URL',
'COOLIFY_URL' => 'https://sapere-10.bobman.dev',
]),
],
);
// The literal `$COOLIFY_URL` must NOT be forwarded inline; it is resolved by the shell
// after sourcing the build-time .env file, then read through the build secret.
expect($command)->toContain('set -a && source /artifacts/build-time.env && set +a');
expect($command)->not->toContain("'BETTER_AUTH_URL=\$COOLIFY_URL'");
expect($command)->not->toContain("env 'BETTER_AUTH_URL");
expect($command)->toContain("--secret 'id=BETTER_AUTH_URL,env=BETTER_AUTH_URL'");
expect($command)->toContain("--secret 'id=COOLIFY_URL,env=COOLIFY_URL'");
});
it('creates an empty build-time env file for railpack when there are no generated build-time variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'build_pack' => 'railpack',
'compose_parsing_version' => '3',
]);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$application = $applicationProperty->getValue($job);
$application->setRelation('settings', new ApplicationSetting([
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]));
$application->setRelation('environment_variables', collect([
new EnvironmentVariable(['key' => 'COOLIFY_FQDN']),
new EnvironmentVariable(['key' => 'COOLIFY_URL']),
new EnvironmentVariable(['key' => 'COOLIFY_BRANCH']),
new EnvironmentVariable(['key' => 'COOLIFY_RESOURCE_UUID']),
]));
foreach ([
'application_deployment_queue' => new class
{
public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {}
},
'build_pack' => 'railpack',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
invokeRailpackMethod($job, $reflection, 'save_buildtime_environment_variables');
expect(collect($job->recordedCommands)->flatten()->implode(' '))->toContain('touch /artifacts/build-time.env');
});
+1 -1
View File
@@ -182,7 +182,7 @@ test('escapeshellarg neutralizes command injection in mariadb password', functio
$escaped = escapeshellarg($maliciousPassword);
// Single quotes in the value get escaped as '\''
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
expect($escaped)->toBe("'pass'\\''; whoami; echo '\\'''");
$command = "docker exec container mariadb-dump -u root -p$escaped db";
// Verify the command doesn't contain an unescaped semicolon outside quotes
expect($command)->toContain("-p'pass'");
+87 -31
View File
@@ -4,11 +4,50 @@ use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Support\Collection;
afterEach(function () {
Mockery::close();
});
function commandStrings(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
return collect([$commands]);
}
return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command);
}
function privateKeyMaterializationCommands(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
$commands = [$commands];
}
return collect($commands)->filter(fn ($command) => str(commandStrings([$command])->first())->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_'));
}
function expectCommandListToContain(array|Collection|string $commands, string $expected): void
{
expect(commandStrings($commands)->implode(' && '))->toContain($expected);
}
function expectCommandListNotToContain(array|Collection|string $commands, string $expected): void
{
expect(commandStrings($commands)->implode(' && '))->not->toContain($expected);
}
function expectPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void
{
$keyCommands = privateKeyMaterializationCommands($commands);
expect($keyCommands)->not->toBeEmpty();
$keyCommands->each(function ($command): void {
expect(data_get($command, 'skip_command_log'))->toBeTrue();
});
}
/**
* Git operations authenticate with the SSH key assigned in the UI. Coolify writes that key to a
* per-deployment path (/root/.ssh/id_rsa_coolify_<deployment_uuid>) instead of the shared
@@ -19,6 +58,24 @@ afterEach(function () {
*/
$keyPath = '/root/.ssh/id_rsa_coolify_test-deployment-uuid';
it('skips logging the docker deploy key materialization command before ls-remote', function () {
$source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
$commandPosition = strpos($source, 'base64 -d | tee {$customSshKeyLocation}');
expect($commandPosition)->not->toBeFalse()
->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true");
});
it('supports skipping command log entries without adding a hidden command entry', function () {
$source = file_get_contents(__DIR__.'/../../app/Traits/ExecuteRemoteCommand.php');
expect($source)
->toContain('$skip_command_log = data_get($single_command, \'skip_command_log\', false);')
->toContain('if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue))')
->toContain('use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log)')
->toContain('\'command\' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),');
});
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
@@ -31,11 +88,11 @@ it('writes a deploy key to a per-deployment path and cleans it up for ls-remote
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT") // removed when the shell exits
->not->toContain('tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT"); // removed when the shell exits
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >'); // never overwrites the host root's own key
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a deploy key to a per-deployment path for ls-remote inside docker without a trap', function () use ($keyPath) {
@@ -50,11 +107,11 @@ it('writes a deploy key to a per-deployment path for ls-remote inside docker wit
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', true);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->not->toContain('trap ') // ephemeral container, no cleanup needed
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListNotToContain($result['commands'], 'trap '); // ephemeral container, no cleanup needed
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a GitLab source private key to a per-deployment path with cleanup on the host', function () use ($keyPath) {
@@ -77,11 +134,11 @@ it('writes a GitLab source private key to a per-deployment path with cleanup on
$result = $application->generateGitLsRemoteCommands('test-deployment-uuid', false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a deploy key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
@@ -104,11 +161,11 @@ it('writes a deploy key to a per-deployment path and cleans it up when cloning o
// exec_in_docker = false → the loadComposeFile / host clone path
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('writes a GitLab source private key to a per-deployment path and cleans it up when cloning on the host', function () use ($keyPath) {
@@ -137,11 +194,11 @@ it('writes a GitLab source private key to a per-deployment path and cleans it up
$result = $application->generateGitImportCommands('test-deployment-uuid', 0, null, false);
expect($result['commands'])
->toContain("tee {$keyPath}")
->toContain("-i {$keyPath} -o IdentitiesOnly=yes")
->toContain("trap 'rm -f {$keyPath}' EXIT")
->not->toContain('tee /root/.ssh/id_rsa >');
expectCommandListToContain($result['commands'], "tee {$keyPath}");
expectCommandListToContain($result['commands'], "-i {$keyPath} -o IdentitiesOnly=yes");
expectCommandListToContain($result['commands'], "trap 'rm -f {$keyPath}' EXIT");
expectCommandListNotToContain($result['commands'], 'tee /root/.ssh/id_rsa >');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('uses the per-deployment deploy key for pull request fetches', function () use ($keyPath) {
@@ -163,9 +220,9 @@ it('uses the per-deployment deploy key for pull request fetches', function () us
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain("GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify")
->not->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectCommandListToContain($result['commands'], "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$keyPath} -o IdentitiesOnly=yes\" git fetch origin pull/123/head:pr-123-coolify");
expectCommandListNotToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('does not force a missing per-deployment key for other repository pull request fetches', function () use ($keyPath) {
@@ -183,7 +240,6 @@ it('does not force a missing per-deployment key for other repository pull reques
$result = $application->generateGitImportCommands('test-deployment-uuid', 123, 'github', false);
expect($result['commands'])
->toContain('GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify')
->not->toContain($keyPath);
expectCommandListToContain($result['commands'], 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa" git fetch origin pull/123/head:pr-123-coolify');
expectCommandListNotToContain($result['commands'], $keyPath);
});
+74
View File
@@ -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}');
});
+47 -9
View File
@@ -3,11 +3,45 @@
use App\Models\Application;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Illuminate\Support\Collection;
afterEach(function () {
Mockery::close();
});
function gitlabCommandStrings(array|Collection|string $commands): Collection
{
if (is_string($commands)) {
return collect([$commands]);
}
return collect($commands)->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command);
}
function expectGitlabCommandListToContain(array|Collection|string $commands, string $expected): void
{
expect(gitlabCommandStrings($commands)->implode(' && '))->toContain($expected);
}
function expectGitlabCommandListNotToContain(array|Collection|string $commands, string $expected): void
{
expect(gitlabCommandStrings($commands)->implode(' && '))->not->toContain($expected);
}
function expectGitlabPrivateKeyMaterializationCommandsSkipLogging(array|Collection|string $commands): void
{
if (is_string($commands)) {
$commands = [$commands];
}
$keyCommands = collect($commands)->filter(fn ($command) => str(data_get($command, 'command') ?? $command[0] ?? $command)->contains('base64 -d | tee /root/.ssh/id_rsa_coolify_'));
expect($keyCommands)->not->toBeEmpty();
$keyCommands->each(function ($command): void {
expect(data_get($command, 'skip_command_log'))->toBeTrue();
});
}
it('generates ls-remote commands for GitLab source with private key', function () {
$deploymentUuid = 'test-deployment-uuid';
@@ -15,7 +49,8 @@ it('generates ls-remote commands for GitLab source with private key', function (
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
@@ -34,16 +69,18 @@ it('generates ls-remote commands for GitLab source with private key', function (
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('id_rsa');
expect($result['commands'])->toContain('mkdir -p /root/.ssh');
expectGitlabCommandListToContain($result['commands'], 'git ls-remote');
expectGitlabCommandListToContain($result['commands'], 'id_rsa');
expectGitlabCommandListToContain($result['commands'], 'mkdir -p /root/.ssh');
expectGitlabPrivateKeyMaterializationCommandsSkipLogging($result['commands']);
});
it('generates ls-remote commands for GitLab source without private key', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
@@ -61,17 +98,18 @@ it('generates ls-remote commands for GitLab source without private key', functio
expect($result)->toBeArray();
expect($result)->toHaveKey('commands');
expect($result['commands'])->toContain('git ls-remote');
expect($result['commands'])->toContain('https://gitlab.com/user/repo.git');
expectGitlabCommandListToContain($result['commands'], 'git ls-remote');
expectGitlabCommandListToContain($result['commands'], 'https://gitlab.com/user/repo.git');
// Should NOT contain SSH key setup
expect($result['commands'])->not->toContain('id_rsa');
expectGitlabCommandListNotToContain($result['commands'], 'id_rsa');
});
it('does not return null for GitLab source type', function () {
$deploymentUuid = 'test-deployment-uuid';
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
$gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class);
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
@@ -34,7 +34,7 @@ test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain("gunzip -cf '/tmp/test.dump'");
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
@@ -46,7 +46,7 @@ test('buildRestoreCommand handles MySQL without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
@@ -57,7 +57,7 @@ test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
@@ -68,5 +68,5 @@ test('buildRestoreCommand always appends the MongoDB archive path', function (bo
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('--archive=/tmp/test.dump');
expect($result)->toContain("--archive='/tmp/test.dump'");
})->with([false, true]);
@@ -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();
});
+92
View File
@@ -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',
]);
@@ -37,10 +37,8 @@ test('validateBucketName accepts valid bucket names', function () {
// Valid bucket names
expect($method->invoke($component, 'my-bucket'))->toBeTrue();
expect($method->invoke($component, 'my_bucket'))->toBeTrue();
expect($method->invoke($component, 'mybucket123'))->toBeTrue();
expect($method->invoke($component, 'my.bucket.name'))->toBeTrue();
expect($method->invoke($component, 'Bucket-Name_123'))->toBeTrue();
});
test('validateBucketName rejects invalid bucket names', function () {
@@ -55,6 +53,9 @@ test('validateBucketName rejects invalid bucket names', function () {
expect($method->invoke($component, 'bucket&ls'))->toBeFalse();
expect($method->invoke($component, "bucket\nid"))->toBeFalse();
expect($method->invoke($component, 'bucket name'))->toBeFalse(); // Space not allowed in bucket
expect($method->invoke($component, 'my_bucket'))->toBeFalse();
expect($method->invoke($component, 'Bucket-Name'))->toBeFalse();
expect($method->invoke($component, '192.168.1.1'))->toBeFalse();
});
test('validateS3Path accepts valid S3 paths', function () {
@@ -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',
]);
+26
View File
@@ -53,6 +53,7 @@ test('S3Storage model fillable attributes are configured correctly', function ()
$s3Storage = new S3Storage;
expect($s3Storage->getFillable())->toBe([
'team_id',
'name',
'description',
'region',
@@ -74,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;
}))
@@ -118,3 +120,27 @@ test('S3Storage connection validation returns friendly timeout error', function
expect($s3Storage->is_usable)->toBeFalse();
});
test('S3Storage testConnection rejects invalid bucket before building client', function (string $bucket) {
Storage::shouldReceive('build')->never();
$s3Storage = new S3Storage;
$s3Storage->setRawAttributes([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'AKIAEXAMPLE',
'secret' => 'secret',
'bucket' => $bucket,
'endpoint' => 'https://s3.amazonaws.com',
]);
expect(fn () => $s3Storage->testConnection())
->toThrow(RuntimeException::class, 'S3 bucket name is not allowed');
})->with([
'semicolon injection' => ['lab; id; #'],
'command substitution' => ['lab$(id)'],
'backticks' => ['lab`id`'],
'newline' => ["lab\nid"],
'underscore' => ['lab_bucket'],
'uppercase' => ['LabBucket'],
]);
+40 -1
View File
@@ -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) {
+29
View File
@@ -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) {
+14 -17
View File
@@ -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'],
@@ -0,0 +1,24 @@
<?php
use App\Livewire\Storage\Create;
use App\Livewire\Storage\Form;
use App\Rules\ValidS3BucketName;
function storageRulesFor(string $componentClass): array
{
$component = new $componentClass;
$method = new ReflectionMethod($component, 'rules');
$method->setAccessible(true);
return $method->invoke($component);
}
it('uses the shared S3 bucket rule in storage create and edit forms', function (string $componentClass) {
$bucketRules = storageRulesFor($componentClass)['bucket'];
expect($bucketRules)->toContain('required')
->and(collect($bucketRules)->contains(fn ($rule) => $rule instanceof ValidS3BucketName))->toBeTrue();
})->with([
'create form' => [Create::class],
'edit form' => [Form::class],
]);
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Rules\ValidS3BucketName;
function validS3BucketNameRulePasses(string $bucket): bool
{
$failed = false;
(new ValidS3BucketName)->validate('bucket', $bucket, function () use (&$failed) {
$failed = true;
});
return ! $failed;
}
it('accepts valid s3 bucket names', function (string $bucket) {
expect(validS3BucketNameRulePasses($bucket))->toBeTrue("Expected accepted: {$bucket}");
})->with([
'short' => ['abc'],
'simple' => ['coolify-backups'],
'dots' => ['coolify.backups'],
'digits' => ['backup-123'],
'max length' => [str_repeat('a', 63)],
]);
it('rejects invalid s3 bucket names and injection payloads', function (string $bucket) {
expect(validS3BucketNameRulePasses($bucket))->toBeFalse("Expected rejected: {$bucket}");
})->with([
'too short' => ['ab'],
'too long' => [str_repeat('a', 64)],
'uppercase' => ['CoolifyBackups'],
'underscore' => ['coolify_backups'],
'leading hyphen' => ['-coolify-backups'],
'trailing hyphen' => ['coolify-backups-'],
'leading dot' => ['.coolify-backups'],
'trailing dot' => ['coolify-backups.'],
'consecutive dots' => ['coolify..backups'],
'dot hyphen' => ['coolify.-backups'],
'hyphen dot' => ['coolify-.backups'],
'ipv4 address' => ['192.168.1.1'],
'semicolon injection' => ['lab; id; #'],
'command substitution' => ['lab$(id)'],
'backticks' => ['lab`id`'],
'pipe' => ['lab|id'],
'ampersand' => ['lab&id'],
'space' => ['lab bucket'],
'newline' => ["lab\nid"],
]);
+15 -8
View File
@@ -132,24 +132,31 @@ it('generates nullable dockerNetworkRules when not required', function () {
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
it('accepts shell-safe environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'railpack control variable' => 'RAILPACK_NODE_VERSION',
'digits after first character' => 'NODE_VERSION_20',
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'lowercase' => 'node_version',
'dot notation' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
it('rejects invalid environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
'command substitution' => 'BAD$(id)',
'backticks' => 'BAD`id`',
'pipe' => 'BAD|id',
'ampersand' => 'BAD&id',
'newline' => 'BAD
KEY',
'equals' => 'BAD=KEY',
'empty' => '',
]);
@@ -164,7 +171,7 @@ it('generates environment variable key rules with correct defaults', function ()
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' APP_ENV '))->toBe('APP_ENV');
});
it('normalizes environment variable keys before model validation', function () {