Files
coolify/tests/Feature/Security/ApiSensitiveFieldsTest.php
T
Andras Bacsai c97f916052 fix(api): hide application compose PR fields
Add compose PR fields to the Application hidden attributes and allow database list queries to eager load nested server settings when sensitive access is permitted.
2026-05-11 11:46:05 +02:00

210 lines
7.7 KiB
PHP

<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function makeApiToken(User $user, Team $team, array $abilities): string
{
session(['currentTeam' => $team]);
$token = $user->createToken('sensitive-test', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
return $token->plainTextToken;
}
function makeTeamUser(): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
return [$team, $user];
}
beforeEach(function () {
InstanceSettings::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
[$this->team, $this->user] = makeTeamUser();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->forceFill([
'sentinel_token' => encrypt('super-secret-sentinel-token'),
'sentinel_custom_url' => 'https://sentinel.internal',
'logdrain_axiom_api_key' => encrypt('axiom-key-secret'),
'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'),
'logdrain_custom_config' => 'custom-config-data',
'logdrain_custom_config_parser' => 'custom-parser-data',
])->saveQuietly();
});
describe('GET /api/v1/servers sensitive field gating', function () {
test('read token does not leak sentinel or logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('sentinel_token');
expect($body)->not->toContain('sentinel_custom_url');
expect($body)->not->toContain('logdrain_axiom_api_key');
expect($body)->not->toContain('logdrain_newrelic_license_key');
expect($body)->not->toContain('logdrain_custom_config');
});
test('read sensitive token sees sentinel and logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
expect($body)->toContain('sentinel_custom_url');
expect($body)->toContain('logdrain_axiom_api_key');
});
test('root token sees sentinel and logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
});
});
describe('GET /api/v1/applications nested-relation scrubbing', function () {
beforeEach(function () {
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$this->application = Application::create([
'name' => 'sensitive-test-app',
'git_repository' => 'https://github.com/test/test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('read token does not leak sentinel_token via destination.server.settings', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/applications');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('"sentinel_token":');
expect($body)->not->toContain('"sentinel_custom_url":');
expect($body)->not->toContain('"logdrain_axiom_api_key":');
});
test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/applications');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"sentinel_token":');
expect($body)->toContain('"sentinel_custom_url":');
});
});
describe('GET /api/v1/databases sensitive field gating', function () {
beforeEach(function () {
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$this->database = StandalonePostgresql::create([
'name' => 'sensitive-db',
'description' => 'test',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('super-secret-db-password'),
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('read token does not leak postgres_password or db urls', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/databases');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('postgres_password');
expect($body)->not->toContain('internal_db_url');
expect($body)->not->toContain('external_db_url');
});
test('read sensitive token sees postgres_password and nested sentinel_token', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/databases');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"postgres_password":');
expect($body)->toContain('"internal_db_url":');
expect($body)->toContain('"sentinel_token":');
});
test('project database list can eager load nested destination server settings', function () {
$databases = $this->project->databases(['destination.server.settings']);
$database = $databases->firstWhere('id', $this->database->id);
expect($database)->not->toBeNull()
->and($database->relationLoaded('destination'))->toBeTrue()
->and($database->destination->relationLoaded('server'))->toBeTrue()
->and($database->destination->server->relationLoaded('settings'))->toBeTrue();
});
});