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:
parent
f5ecdfa4ce
commit
6871160623
9 changed files with 214 additions and 36 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue