fix: harden GetLogs Livewire component properties - #9229
Conversation
…ut validation Add #[Locked] attributes to security-sensitive properties (resource, servicesubtype, server, container) to prevent client-side modification via Livewire wire protocol. Add container name validation using ValidationPatterns::isValidContainerName() and server ownership authorization via Server::ownedByCurrentTeam() in both getLogs() and downloadAllLogs() methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace $guarded = [] with explicit $fillable whitelists across all models. Update controllers to use request->only($allowedFields) when assigning request data. Switch Livewire components to forceFill() for explicit mass assignment. Add integration tests for mass assignment protection.
…vewire-properties
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
WalkthroughThis pull request implements mass-assignment protection across the application by replacing unguarded Eloquent models ( ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
app/Http/Controllers/Api/ApplicationsController.php (1)
1946-2003:⚠️ Potential issue | 🟠 MajorTwo dockercompose flags just became decorative switches.
This branch still documents
use_build_serverandconnect_to_docker_network, but the dockercompose-specific$allowedFieldsomits both. With the newonly($allowedFields)plus the extra-fields check, those requests now 422 beforeService::fill()runs. Add both keys to the local allowlist or map them explicitly before filling theService.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Http/Controllers/Api/ApplicationsController.php` around lines 1946 - 2003, The request allowlist ($allowedFields) used before Service::fill() is missing the docker-compose flags use_build_server and connect_to_docker_network, causing valid requests to 422; update the $allowedFields array to include 'use_build_server' and 'connect_to_docker_network' or, alternatively, set those two keys on the Request (or on $service) explicitly after the extra-fields check but before calling removeUnnecessaryFieldsFromRequest() and $service->fill($request->only($allowedFields)), ensuring the flags are present when Service::fill() runs.app/Http/Controllers/Api/SecurityController.php (1)
298-334:⚠️ Potential issue | 🔴 CriticalThis PATCH action terminates itself before the update.
$foundKeyis resolved with$request->uuid, butuuidis neither part of the route nor allowed by the extra-fields guard, and the request schema above disallows it too. Any client that sendsuuidgets a 422 before the lookup, so this endpoint cannot update anything under its advertised contract. Acceptuuidas a validated identifier (ideally a route param) while keeping it out of the mass-assignment payload.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Http/Controllers/Api/SecurityController.php` around lines 298 - 334, The PATCH currently fails because the lookup uses $request->uuid but uuid is neither accepted by customApiValidator nor included in $allowedFields, so sending uuid triggers the extra-fields validation and prevents the update; fix by accepting uuid as a validated identifier but not as a mass-assignable field: retrieve uuid from the route (e.g., $uuid = $request->route('uuid')) or add 'uuid' to your validator rules as a required|string but do NOT include 'uuid' in $allowedFields, adjust the extraFields check to ignore route-sourced uuid (or remove it from array_diff) and use PrivateKey::where('team_id', $teamId)->where('uuid', $uuid)->first() so $foundKey->update($request->only($allowedFields)) continues to exclude uuid from mass assignment.bootstrap/helpers/applications.php (1)
209-221:⚠️ Potential issue | 🔴 CriticalPersist the destination morph type during application clone.
This payload updates
destination_idbut notdestination_type, andreplicate()preserves the old morph class. Cloning between different destination backends will save a broken relation that points at the new id in the old table. The clone lives; the morph pointer time-travels.🔧 Suggested fix
])->forceFill(array_merge([ 'uuid' => $uuid, 'name' => $name, 'fqdn' => $url, 'status' => 'exited', 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), ], $overrides));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bootstrap/helpers/applications.php` around lines 209 - 221, The clone currently updates destination_id but leaves the old morph class intact; update the forceFill payload for $newApplication (the result of $source->replicate([...])->forceFill(...)) to also set 'destination_type' to the correct morph class for the new destination (e.g. $destination->getMorphClass() or get_class($destination)) so the cloned application points to the correct polymorphic table.app/Livewire/Project/Shared/ResourceOperations.php (1)
93-103:⚠️ Potential issue | 🔴 CriticalSet
destination_typeon cloned databases.
replicate()keeps the source morph type. If this action clones a database fromStandaloneDockertoSwarmDockeror back, only swappingdestination_idleaves the new row resolving against the wrong destination table. Tiny Skynet bug. Write both keys from$new_destinationhere.🔧 Suggested fix
])->forceFill([ 'uuid' => $uuid, 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', 'started_at' => null, 'destination_id' => $new_destination->id, + 'destination_type' => $new_destination->getMorphClass(), ]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Livewire/Project/Shared/ResourceOperations.php` around lines 93 - 103, The clone currently only updates destination_id leaving destination_type from the source; update the forceFill call on the replicated resource so it sets both destination_id and destination_type using the destination object's morph class (e.g. $new_destination->getMorphClass()) so the cloned $new_resource resolves to the correct destination table; locate the replicate(...)->forceFill(...) chain in ResourceOperations.php and add destination_type => $new_destination->getMorphClass() alongside destination_id.app/Livewire/Project/CloneMe.php (1)
91-149:⚠️ Potential issue | 🔴 CriticalRe-resolve and authorize the selected destination server-side.
selectedDestinationis a public Livewire property, so the client can tamper with it. This action only checks that it exists; applications try to look up a destination from component state, but databases and services copy the raw id straight intodestination_id, and those payloads never persistdestination_type. That opens an authorization hole and can save broken morphs when the backend type changes. Query the destination fromcurrentTeam()inside the action, add the action-level authorization check, and use itsid+getMorphClass()for every clone.Based on learnings: Validate and authorize in Livewire component actions; they behave like HTTP requests.
Also applies to: 255-265
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Livewire/Project/CloneMe.php` around lines 91 - 149, The clone action is trusting the public Livewire property selectedDestination; re-resolve and authorize the destination on the server and use its id + morph class for all clones: inside clone(string $type) query the destination via currentTeam()->servers()->with('destinations')->whereHas('destinations', fn($q)=>$q->where('id',$this->selectedDestination))->firstOrFail() and then find the destination model (e.g. $destination = $server->destinations()->where('id',$this->selectedDestination)->firstOrFail()), run an authorization check (authorize or Gate::forUser(currentUser())->check(...) as your app uses) and then pass that $destination object into clone_application($application, $destination, ...) instead of resolving from $this->servers; when creating databases and services replace direct destination_id = $this->selectedDestination with destination_id = $destination->id and set destination_type = $destination->getMorphClass() so both id and type are persisted consistently and safely (also apply same change in the other clone block referenced for lines ~255-265).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Models/Application.php`:
- Around line 1769-1771: The RuntimeException throw exposes raw git errors (e.g.
"{$gitRemoteStatus['error']}") which can contain credentials; replace the
user-facing error text with a redacted or generic message (e.g. "Failed to read
Git source; check repository access") and ensure the full
$gitRemoteStatus['error'] is written only to internal/server logs (not returned
to the UI). Apply this change where the code checks
$gitRemoteStatus['is_accessible'] and throws RuntimeException (the shown throw
and the other occurrence around 1813-1829), and implement a small sanitizer if
you prefer to keep partial context (strip user:pass@ from URLs) before including
any shortened detail in the public message.
In `@app/Models/StandaloneClickhouse.php`:
- Around line 19-20: The Eloquent model StandaloneClickhouse has a typo in its
$casts array: it casts 'clickhouse_password' instead of the actual column name
'clickhouse_admin_password', so the admin password is not being encrypted;
update the $casts entry to use the exact column key 'clickhouse_admin_password'
(matching the $fillable entry) and ensure the cast uses the 'encrypted' or
equivalent cast type used elsewhere so the password is stored encrypted.
In `@app/Models/StandaloneRedis.php`:
- Around line 16-20: The model currently exposes 'redis_password' in the
$fillable array even though create_standalone_redis() stores the password in the
REDIS_PASSWORD env var and redisPassword() reads it from
runtime_environment_variables(); remove 'redis_password' from the
StandaloneRedis::$fillable to prevent misleading mass-assignment, and if you
need runtime password updates implement them via the environment-variable update
path used by runtime_environment_variables() (e.g., update logic invoked by
create_standalone_redis() or a dedicated env-var updater) rather than relying on
Model::update($request->only(['redis_password'])).
In `@app/Models/Team.php`:
- Around line 43-50: Update the Team model's $fillable array to remove migrated
attributes and add the missing personal_team: remove
'use_instance_email_settings' and 'resend_api_key' from Team::$fillable and
ensure 'personal_team' is present alongside 'name', 'description',
'show_boarding', and 'custom_server_limit'; leave notification fields on
EmailNotificationSettings model only. Then in Api/TeamController
(removeSensitiveData method) delete the code that attempts to hide
'smtp_username', 'smtp_password', 'telegram_token', and 'resend_api_key' from
Team instances since those columns no longer exist on the teams table and are
handled by their respective notification settings models.
In `@app/Models/User.php`:
- Around line 46-55: The User model has removed 'id' from $fillable which causes
calls like User::create(['id' => 0, ...]) in
app/Actions/Fortify/CreateNewUser.php and database/seeders/RootUserSeeder.php to
silently drop the id (breaking the root-user bootstrap and User::boot()
special-case); update those trusted writers to set the id explicitly using
forceFill(['id' => 0])->save() or assign $user->id = 0 before saving (or
otherwise use manual assignment) instead of relying on mass-assignment, ensuring
the root seeder and CreateNewUser action create the root user with id 0 so
User::boot() can create the root team.
- Around line 46-55: Remove 'pending_email', 'email_change_code', and
'email_change_code_expires_at' from the User::$fillable array so they are not
mass-assignable, and update the email-change flow to set those fields using
$this->forceFill([...])->save() inside the requestEmailChange() method (keeping
the server-generated $code and $expiresAt). Also add or update
MassAssignmentProtectionTest to assert these fields cannot be mass-assigned to
prevent regressions.
In `@tests/Feature/GetLogsCommandInjectionTest.php`:
- Around line 38-55: Replace the reflection-based tests with Livewire action
tests that exercise the actual Livewire component invoking GetLogs::getLogs and
GetLogs::downloadAllLogs: call the Livewire action with invalid container names
and with a server not owned by the current team, assert the response
aborts/returns authorization/validation errors and ensure no docker/command
execution path is reached by spying/mocking the command execution helper (or
asserting the command runner was not called). Specifically target the Livewire
component method that routes to GetLogs actions, provide inputs that would fail
ValidationPatterns::isValidContainerName and a Server where
Server::ownedByCurrentTeam() is false, and assert validation/authorization
happens before any command assembly or runner invocation. Ensure you replace the
ReflectionMethod checks for strings with these interaction assertions.
In `@tests/Feature/MassAssignmentProtectionTest.php`:
- Around line 109-114: The Team model's personal_team attribute is not fillable
so calls like Team::create(['personal_team' => true, ...]) from User::boot() and
User::recreate_personal_team() silently drop the flag; fix by either (A)
updating User::boot() and User::recreate_personal_team() to set the flag after
creation (e.g., create the Team, then $team->personal_team = true;
$team->save()) or (B) use $team = Team::forceCreate(...) or $team = (new
Team)->forceFill([...])->save() when creating personal teams; do not change
tests—modify the Team creation code paths in the User methods referenced to
ensure personal_team is persisted.
---
Outside diff comments:
In `@app/Http/Controllers/Api/ApplicationsController.php`:
- Around line 1946-2003: The request allowlist ($allowedFields) used before
Service::fill() is missing the docker-compose flags use_build_server and
connect_to_docker_network, causing valid requests to 422; update the
$allowedFields array to include 'use_build_server' and
'connect_to_docker_network' or, alternatively, set those two keys on the Request
(or on $service) explicitly after the extra-fields check but before calling
removeUnnecessaryFieldsFromRequest() and
$service->fill($request->only($allowedFields)), ensuring the flags are present
when Service::fill() runs.
In `@app/Http/Controllers/Api/SecurityController.php`:
- Around line 298-334: The PATCH currently fails because the lookup uses
$request->uuid but uuid is neither accepted by customApiValidator nor included
in $allowedFields, so sending uuid triggers the extra-fields validation and
prevents the update; fix by accepting uuid as a validated identifier but not as
a mass-assignable field: retrieve uuid from the route (e.g., $uuid =
$request->route('uuid')) or add 'uuid' to your validator rules as a
required|string but do NOT include 'uuid' in $allowedFields, adjust the
extraFields check to ignore route-sourced uuid (or remove it from array_diff)
and use PrivateKey::where('team_id', $teamId)->where('uuid', $uuid)->first() so
$foundKey->update($request->only($allowedFields)) continues to exclude uuid from
mass assignment.
In `@app/Livewire/Project/CloneMe.php`:
- Around line 91-149: The clone action is trusting the public Livewire property
selectedDestination; re-resolve and authorize the destination on the server and
use its id + morph class for all clones: inside clone(string $type) query the
destination via
currentTeam()->servers()->with('destinations')->whereHas('destinations',
fn($q)=>$q->where('id',$this->selectedDestination))->firstOrFail() and then find
the destination model (e.g. $destination =
$server->destinations()->where('id',$this->selectedDestination)->firstOrFail()),
run an authorization check (authorize or
Gate::forUser(currentUser())->check(...) as your app uses) and then pass that
$destination object into clone_application($application, $destination, ...)
instead of resolving from $this->servers; when creating databases and services
replace direct destination_id = $this->selectedDestination with destination_id =
$destination->id and set destination_type = $destination->getMorphClass() so
both id and type are persisted consistently and safely (also apply same change
in the other clone block referenced for lines ~255-265).
In `@app/Livewire/Project/Shared/ResourceOperations.php`:
- Around line 93-103: The clone currently only updates destination_id leaving
destination_type from the source; update the forceFill call on the replicated
resource so it sets both destination_id and destination_type using the
destination object's morph class (e.g. $new_destination->getMorphClass()) so the
cloned $new_resource resolves to the correct destination table; locate the
replicate(...)->forceFill(...) chain in ResourceOperations.php and add
destination_type => $new_destination->getMorphClass() alongside destination_id.
In `@bootstrap/helpers/applications.php`:
- Around line 209-221: The clone currently updates destination_id but leaves the
old morph class intact; update the forceFill payload for $newApplication (the
result of $source->replicate([...])->forceFill(...)) to also set
'destination_type' to the correct morph class for the new destination (e.g.
$destination->getMorphClass() or get_class($destination)) so the cloned
application points to the correct polymorphic table.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b9e35ff8-9f66-4e94-9b95-d47b2432d545
📒 Files selected for processing (22)
app/Http/Controllers/Api/ApplicationsController.phpapp/Http/Controllers/Api/DatabasesController.phpapp/Http/Controllers/Api/SecurityController.phpapp/Livewire/Project/CloneMe.phpapp/Livewire/Project/Shared/GetLogs.phpapp/Livewire/Project/Shared/ResourceOperations.phpapp/Models/Application.phpapp/Models/Server.phpapp/Models/Service.phpapp/Models/StandaloneClickhouse.phpapp/Models/StandaloneDragonfly.phpapp/Models/StandaloneKeydb.phpapp/Models/StandaloneMariadb.phpapp/Models/StandaloneMongodb.phpapp/Models/StandaloneMysql.phpapp/Models/StandalonePostgresql.phpapp/Models/StandaloneRedis.phpapp/Models/Team.phpapp/Models/User.phpbootstrap/helpers/applications.phptests/Feature/GetLogsCommandInjectionTest.phptests/Feature/MassAssignmentProtectionTest.php
💤 Files with no reviewable changes (1)
- app/Models/Server.php
Restrict mass-assignable attributes across user/team/redis models and switch privileged root/team creation paths to forceFill/forceCreate. Encrypt legacy ClickHouse admin passwords via migration and cast the correct ClickHouse password field as encrypted. Tighten API and runtime exposure by removing sensitive team fields from responses and sanitizing Git/compose error messages. Expand security-focused feature coverage for command-injection and mass assignment protections.
Summary
#[Locked]attributes to security-sensitive Livewire properties (resource,servicesubtype,server,container) in theGetLogscomponent to prevent unintended client-side modificationValidationPatterns::isValidContainerName()ingetLogs()anddownloadAllLogs()for input consistencyServer::ownedByCurrentTeam()to both methods, aligning with the pattern used inExecuteContainerCommandTest plan
php artisan test --compact tests/Feature/GetLogsCommandInjectionTest.php— 14 tests passphp artisan test --compact tests/Feature/DatabaseImportCommandInjectionTest.php— existing tests pass (no regression)php artisan test --compact tests/Feature/CommandInjectionSecurityTest.php— broader security suite passes🤖 Generated with Claude Code