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:
Andras Bacsai 2026-07-02 15:50:43 +02:00
parent f5ecdfa4ce
commit 6871160623
9 changed files with 214 additions and 36 deletions

View file

@ -33,6 +33,15 @@
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 @@ public function application_by_uuid(Request $request)
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 @@ public function logs_by_uuid(Request $request)
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 @@ public function delete_by_uuid(Request $request)
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 @@ public function update_by_uuid(Request $request)
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 @@ public function storages(Request $request): JsonResponse
$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 @@ public function update_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
}
#[OA\Post(
@ -4429,7 +4439,7 @@ public function create_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
}
#[OA\Delete(

View file

@ -28,6 +28,15 @@
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 @@ public function storages(Request $request): JsonResponse
$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 @@ public function create_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
}
#[OA\Patch(
@ -4062,7 +4072,7 @@ public function update_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
}
#[OA\Delete(

View file

@ -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);
}

View file

@ -24,8 +24,21 @@
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 @@ public function storages(Request $request): JsonResponse
);
}
$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 @@ public function create_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
}
#[OA\Patch(
@ -2502,7 +2517,7 @@ public function update_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
}
#[OA\Delete(

View file

@ -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',
];

View file

@ -24,6 +24,10 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled' => 'boolean',
];
protected $hidden = [
'content',
];
use HasFactory;
protected $fillable = [

View file

@ -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 @@
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 @@
]);
});
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 @@
'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 @@
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 @@
$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 @@
// 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 @@
'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,
]);

View file

@ -18,8 +18,14 @@
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 @@
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 @@ function createTestDatabase($context): StandalonePostgresql
$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 @@ function createTestDatabase($context): StandalonePostgresql
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);

View file

@ -8,6 +8,7 @@
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 @@
'manual_webhook_secret_gitea',
'dockerfile',
'docker_compose',
'docker_compose_pr',
'docker_compose_raw',
'docker_compose_pr_raw',
'custom_labels',
);
});
@ -94,6 +93,12 @@
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();