mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 18:15:24 +00:00
fix(api): gate sensitive storage and GitHub fields
Expose GitHub app secrets and file storage content only when the request has sensitive read access. Hide LocalFileVolume content by default and resolve application UUIDs from route parameters.
This commit is contained in:
1 parent
f5ecdfa4ce
commit
6871160623
9 files changed
+214
-36
No files matched your search
@@ -33,6 +33,15 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible(['content']);
|
||||
}
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
private function removeSensitiveData($application)
|
||||
{
|
||||
$application->makeHidden([
|
||||
@@ -2031,7 +2040,7 @@ class ApplicationsController extends Controller
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
@@ -2112,7 +2121,7 @@ class ApplicationsController extends Controller
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
@@ -2205,7 +2214,7 @@ class ApplicationsController extends Controller
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
@@ -2416,7 +2425,7 @@ class ApplicationsController extends Controller
|
||||
return $return;
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
|
||||
if (! $application) {
|
||||
return response()->json([
|
||||
'message' => 'Application not found',
|
||||
@@ -4028,6 +4037,7 @@ class ApplicationsController extends Controller
|
||||
|
||||
$persistentStorages = $application->persistentStorages->sortBy('id')->values();
|
||||
$fileStorages = $application->fileStorages->sortBy('id')->values();
|
||||
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
|
||||
|
||||
return response()->json([
|
||||
'persistent_storages' => $persistentStorages,
|
||||
@@ -4242,7 +4252,7 @@ class ApplicationsController extends Controller
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
@@ -4429,7 +4439,7 @@ class ApplicationsController extends Controller
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -28,6 +28,15 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible(['content']);
|
||||
}
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
private function removeSensitiveData($database)
|
||||
{
|
||||
$database->makeHidden([
|
||||
@@ -3664,6 +3673,7 @@ class DatabasesController extends Controller
|
||||
|
||||
$persistentStorages = $database->persistentStorages->sortBy('id')->values();
|
||||
$fileStorages = $database->fileStorages->sortBy('id')->values();
|
||||
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
|
||||
|
||||
return response()->json([
|
||||
'persistent_storages' => $persistentStorages,
|
||||
@@ -3855,7 +3865,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -4062,7 +4072,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -17,10 +17,17 @@ class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
{
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$githubApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
} else {
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($githubApp);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,21 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible(['content']);
|
||||
}
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
private function removeSensitiveData($service)
|
||||
{
|
||||
if ($service instanceof Collection) {
|
||||
return $service->map(fn (Service $item) => $this->removeSensitiveData($item));
|
||||
}
|
||||
|
||||
$service->makeHidden([
|
||||
'id',
|
||||
'resourceable',
|
||||
@@ -2065,6 +2078,8 @@ class ServicesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
|
||||
|
||||
return response()->json([
|
||||
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
|
||||
'file_storages' => $fileStorages->sortBy('id')->values(),
|
||||
@@ -2265,7 +2280,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -2502,7 +2517,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -176,11 +176,8 @@ class Application extends BaseModel
|
||||
'manual_webhook_secret_bitbucket',
|
||||
'manual_webhook_secret_gitea',
|
||||
'docker_compose_location',
|
||||
'docker_compose_pr_location',
|
||||
'docker_compose',
|
||||
'docker_compose_pr',
|
||||
'docker_compose_raw',
|
||||
'docker_compose_pr_raw',
|
||||
'docker_compose_domains',
|
||||
'docker_compose_custom_start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
@@ -232,9 +229,7 @@ class Application extends BaseModel
|
||||
'manual_webhook_secret_gitea',
|
||||
'dockerfile',
|
||||
'docker_compose',
|
||||
'docker_compose_pr',
|
||||
'docker_compose_raw',
|
||||
'docker_compose_pr_raw',
|
||||
'custom_labels',
|
||||
];
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ class LocalFileVolume extends BaseModel
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'content',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -9,23 +10,37 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('cache.default', 'array');
|
||||
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
// Create a team with owner
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a private key for the team
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
function createGithubAppsApiToken($context, array $abilities): string
|
||||
{
|
||||
session(['currentTeam' => $context->team]);
|
||||
|
||||
return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/github-apps', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/github-apps');
|
||||
@@ -71,7 +86,7 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not return sensitive data', function () {
|
||||
test('does not return sensitive data for read tokens', function () {
|
||||
// Create a GitHub app
|
||||
GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -86,8 +101,10 @@ describe('GET /api/v1/github-apps', function () {
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$readToken = createGithubAppsApiToken($this, ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
])->getJson('/api/v1/github-apps');
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -98,6 +115,33 @@ describe('GET /api/v1/github-apps', function () {
|
||||
expect($json[0])->not->toHaveKey('webhook_secret');
|
||||
});
|
||||
|
||||
test('returns sensitive data for read sensitive tokens', function () {
|
||||
GithubApp::create([
|
||||
'name' => 'Sensitive GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'secret-should-be-visible',
|
||||
'webhook_secret' => 'webhook-secret-should-be-visible',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
])->getJson('/api/v1/github-apps');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'client_secret' => 'secret-should-be-visible',
|
||||
'webhook_secret' => 'webhook-secret-should-be-visible',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns system-wide github apps', function () {
|
||||
// Create a system-wide GitHub app
|
||||
$systemApp = GithubApp::create([
|
||||
@@ -118,7 +162,8 @@ describe('GET /api/v1/github-apps', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$otherToken = $otherUser->createToken('other-token', ['*']);
|
||||
|
||||
// System-wide apps should be visible to other teams
|
||||
$response = $this->withHeaders([
|
||||
@@ -150,11 +195,6 @@ describe('GET /api/v1/github-apps', function () {
|
||||
|
||||
// Create another team with a GitHub app
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherPrivateKey = PrivateKey::create([
|
||||
'name' => 'Other Key',
|
||||
'private_key' => 'other-key',
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
GithubApp::create([
|
||||
'name' => 'Team 2 App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
@@ -164,7 +204,7 @@ describe('GET /api/v1/github-apps', function () {
|
||||
'client_id' => 'team2-client-id',
|
||||
'client_secret' => 'team2-secret',
|
||||
'webhook_secret' => 'team2-webhook',
|
||||
'private_key_id' => $otherPrivateKey->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
@@ -18,8 +18,14 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('cache.default', 'array');
|
||||
|
||||
Bus::fake();
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -42,8 +48,15 @@ beforeEach(function () {
|
||||
|
||||
function createTestApplication($context): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
return Application::create([
|
||||
'name' => 'test-storage-app',
|
||||
'git_repository' => 'https://github.com/test/test',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,6 +74,19 @@ function createTestDatabase($context): StandalonePostgresql
|
||||
]);
|
||||
}
|
||||
|
||||
function createStorageApiToken($context, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $context->user->tokens()->create([
|
||||
'name' => 'storage-test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $context->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Application Storage Endpoints
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -92,6 +118,39 @@ describe('GET /api/v1/applications/{uuid}/storages', function () {
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('hides file storage content for read tokens and reveals it for sensitive tokens', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => '/tmp/config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"secret": "hidden"}',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$readToken = createStorageApiToken($this, ['read']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['read', 'read:sensitive']);
|
||||
|
||||
$readResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
])->getJson("/api/v1/applications/{$app->uuid}/storages");
|
||||
|
||||
auth()->forgetGuards();
|
||||
|
||||
$sensitiveResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
])->getJson("/api/v1/applications/{$app->uuid}/storages");
|
||||
|
||||
$readResponse->assertSuccessful();
|
||||
$sensitiveResponse->assertSuccessful();
|
||||
|
||||
expect($readResponse->getContent())->not->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
@@ -142,6 +201,39 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () {
|
||||
$app = createTestApplication($this);
|
||||
$writeToken = createStorageApiToken($this, ['write']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']);
|
||||
|
||||
$writeResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/app/hidden.json',
|
||||
'content' => '{"secret": "write-hidden"}',
|
||||
]);
|
||||
|
||||
auth()->forgetGuards();
|
||||
|
||||
$sensitiveResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/app/visible.json',
|
||||
'content' => '{"secret": "write-visible"}',
|
||||
]);
|
||||
|
||||
$writeResponse->assertCreated();
|
||||
$sensitiveResponse->assertCreated();
|
||||
|
||||
expect($writeResponse->getContent())->not->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('write-visible');
|
||||
});
|
||||
|
||||
test('rejects persistent storage without name', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
@@ -63,9 +64,7 @@ describe('Sensitive model fields are hidden by default', function () {
|
||||
'manual_webhook_secret_gitea',
|
||||
'dockerfile',
|
||||
'docker_compose',
|
||||
'docker_compose_pr',
|
||||
'docker_compose_raw',
|
||||
'docker_compose_pr_raw',
|
||||
'custom_labels',
|
||||
);
|
||||
});
|
||||
@@ -94,6 +93,12 @@ describe('Sensitive model fields are hidden by default', function () {
|
||||
expect($hidden)->toContain('logs');
|
||||
});
|
||||
|
||||
test('LocalFileVolume hides file content', function () {
|
||||
$hidden = (new LocalFileVolume)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('content');
|
||||
});
|
||||
|
||||
test('PrivateKey hides private key material', function () {
|
||||
$hidden = (new PrivateKey)->getHidden();
|
||||
|
||||
|
||||
Reference in new issue
Block a user