diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 92f19c7ae..ea3b54e80 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -166,6 +166,9 @@ public function environment_details(Request $request) return response()->json(['message' => 'Environment not found.'], 404); } $environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']); + collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']) + ->flatMap(fn (string $relation) => $environment->{$relation}) + ->each(fn ($resource) => exposeSensitiveFields($resource)); return response()->json(serializeApiResponse($environment)); } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index d5dc4a046..53d542d44 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -56,6 +56,7 @@ public function resources(Request $request) } $resources = $resources->flatten(); $resources = $resources->map(function ($resource) { + exposeSensitiveFields($resource); $payload = $resource->toArray(); $payload['status'] = $resource->status; $payload['type'] = $resource->type(); diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 8d7c51d11..05d1b3e6e 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -11,8 +11,9 @@ public function handle(Request $request, Closure $next) { $token = $request->user()->currentAccessToken(); $hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); - $teamId = (int) data_get($token, 'team_id'); - $isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; + $teamId = data_get($token, 'team_id'); + // team_id 0 is the instance-admin team, so a falsy check must not exclude it + $isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false; // Allow access to sensitive data only if token has permission AND user is admin/owner $request->attributes->add([ diff --git a/app/Models/Application.php b/app/Models/Application.php index 152e1f168..d46e4366b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1281,9 +1281,9 @@ private function legacyConfigurationHash(): string { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); } return md5($newConfigHash); diff --git a/app/Models/Service.php b/app/Models/Service.php index d2de68c7d..ce52687a7 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -105,7 +105,7 @@ public function isConfigurationChanged(bool $save = false) $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); $newConfigHash = $images.$domains.$images.$storages; - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 293e09d02..9db5f21b7 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -134,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index b479a7a2d..769d9f00c 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -133,7 +133,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index cb7b34024..15a1fe2f8 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -134,7 +134,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index fc04e8233..378d36395 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -138,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index ca144d6fd..1010ca5f3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -143,7 +143,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index b7d7ba6b6..90828bf01 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -140,7 +140,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 939ab845f..e7db81285 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -182,7 +182,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index b5615edfe..326261190 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -138,7 +138,7 @@ public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; $newConfigHash .= $this->healthCheckConfigurationHash(); - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); if ($oldConfigHash === null) { diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 6a288a064..be478964b 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -6,6 +6,7 @@ use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -87,6 +88,20 @@ function serializeApiResponse($data) } } +/** + * Re-expose a model's `$hidden` sensitive fields when the current API request + * carries the `read:sensitive` or `root` token ability (set by the + * ApiSensitiveData middleware). + */ +function exposeSensitiveFields(Model $model): Model +{ + if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) { + $model->makeVisible($model->getHidden()); + } + + return $model; +} + function sharedDataApplications() { return [ diff --git a/tests/Feature/DatabaseConfigHashEnvValueTest.php b/tests/Feature/DatabaseConfigHashEnvValueTest.php new file mode 100644 index 000000000..6278c9809 --- /dev/null +++ b/tests/Feature/DatabaseConfigHashEnvValueTest.php @@ -0,0 +1,46 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $database = StandalonePostgresql::create([ + 'name' => 'config-hash-db', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $variable = $database->environment_variables()->create([ + 'key' => 'APP_SECRET', + 'value' => 'original-value', + ]); + + $database->isConfigurationChanged(save: true); + expect($database->refresh()->isConfigurationChanged())->toBeFalse(); + + $variable->update(['value' => 'changed-value']); + + expect($database->refresh()->isConfigurationChanged())->toBeTrue(); +}); diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index 4401a839b..5420ba8c1 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -1001,3 +1001,163 @@ function makeTeamUser(): array expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue(); }); }); + +describe('GET /api/v1/resources 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' => 'resources-sensitive-db', + '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(), + ]); + + $this->application = Application::create([ + 'name' => 'resources-sensitive-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'http_basic_auth_password' => 'basic-auth-secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak database or application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $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'); + expect($body)->not->toContain('http_basic_auth_password'); + }); + + test('read sensitive token sees database and application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"http_basic_auth_password":'); + }); + + test('root token sees database secrets', function () { + $token = makeApiToken($this->user, $this->team, ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/resources'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"postgres_password":'); + }); +}); + +describe('GET /api/v1/projects/{uuid}/{environment} 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' => 'environment-sensitive-db', + '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(), + ]); + + $this->application = Application::create([ + 'name' => 'environment-sensitive-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'http_basic_auth_password' => 'basic-auth-secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak database or application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}"); + + $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'); + expect($body)->not->toContain('http_basic_auth_password'); + }); + + test('read sensitive token sees database and application secrets', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}"); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"http_basic_auth_password":'); + }); +}); + +describe('instance admin team (id 0) sensitive gating', function () { + test('read sensitive token owned by team 0 sees sensitive fields', function () { + $team = Team::factory()->create(['id' => 0]); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->forceFill([ + 'sentinel_token' => encrypt('team-zero-sentinel-token'), + ])->saveQuietly(); + + $token = makeApiToken($user, $team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + expect($response->getContent())->toContain('"sentinel_token":'); + }); +});