From cd02e99d4c3e9de92e2bdca62735d3587b2e33d2 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 31 Jul 2026 15:39:52 +0100 Subject: [PATCH] Livewire: Fixed FD-56800 - Harden livewire mount and boot methods against snapshot replays --- app/Livewire/AdminPersonalAccessTokens.php | 14 ++ .../CustomFieldSetDefaultValuesForModel.php | 15 ++ app/Livewire/LdapSettings.php | 12 +- app/Livewire/LocationScopeCheck.php | 14 ++ app/Livewire/OauthClients.php | 60 ++++++- app/Livewire/PersonalAccessTokens.php | 13 ++ app/Livewire/SlackSettingsForm.php | 15 ++ database/factories/UserFactory.php | 5 + .../OauthClientsAuthorizationTest.php | 157 ++++++++++++++++++ .../Livewire/PersonalAccessTokensTest.php | 23 ++- 10 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/Livewire/OauthClientsAuthorizationTest.php diff --git a/app/Livewire/AdminPersonalAccessTokens.php b/app/Livewire/AdminPersonalAccessTokens.php index b90c08b761..782e7b68bc 100644 --- a/app/Livewire/AdminPersonalAccessTokens.php +++ b/app/Livewire/AdminPersonalAccessTokens.php @@ -11,6 +11,20 @@ use Livewire\Component; */ class AdminPersonalAccessTokens extends Component { + /** + * Route-level middleware on /admin/oauth requires superuser, but + * snapshot replay to POST /livewire/update bypasses that gate. Without + * this check, a low-privilege user with a valid snapshot could enumerate + * every user's personal access tokens (name, expiration, associated + * client) through the render payload. + */ + public function boot(): void + { + if (! auth()->user()?->isSuperUser()) { + abort(403); + } + } + public function render() { $tokens = DB::table('oauth_access_tokens') diff --git a/app/Livewire/CustomFieldSetDefaultValuesForModel.php b/app/Livewire/CustomFieldSetDefaultValuesForModel.php index c37d5e091b..3f20860f6f 100644 --- a/app/Livewire/CustomFieldSetDefaultValuesForModel.php +++ b/app/Livewire/CustomFieldSetDefaultValuesForModel.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\AssetModel; use App\Models\CustomField; use App\Models\CustomFieldset; +use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Computed; use Livewire\Component; @@ -18,6 +19,20 @@ class CustomFieldSetDefaultValuesForModel extends Component public array $selectedValues = []; + /** + * Route-level middleware on the model create/edit pages requires + * AssetModel update permission, but snapshot replay to POST + * /livewire/update bypasses that gate. Without this check, a + * low-privilege user with a valid snapshot could enumerate custom-field + * default values for any asset model by swapping model_id. + */ + public function boot(): void + { + if (! Gate::allows('update', AssetModel::class)) { + abort(403); + } + } + public function mount($model_id = null) { $this->model_id = $model_id; diff --git a/app/Livewire/LdapSettings.php b/app/Livewire/LdapSettings.php index d770264571..a5b22536a2 100644 --- a/app/Livewire/LdapSettings.php +++ b/app/Livewire/LdapSettings.php @@ -170,10 +170,20 @@ class LdapSettings extends Component public string $step3TestDn = ''; - public function mount(): void + /** + * mount() only fires on the initial page render, not on subsequent + * POST /livewire/update requests. Route-level middleware on the LDAP + * settings wizard requires superadmin, but a snapshot replay lands + * here without going through that middleware. boot() runs on every + * Livewire request, so it catches both surfaces. + */ + public function boot(): void { abort_unless(Gate::allows('superadmin'), 403); + } + public function mount(): void + { $this->hydrateFromPersisted(); // Restore in-flight wizard progress from the session so a page diff --git a/app/Livewire/LocationScopeCheck.php b/app/Livewire/LocationScopeCheck.php index f83a2809bf..c3c0e96c87 100644 --- a/app/Livewire/LocationScopeCheck.php +++ b/app/Livewire/LocationScopeCheck.php @@ -14,6 +14,20 @@ class LocationScopeCheck extends Component public $is_tested = false; + /** + * Route-level middleware on /admin/settings requires superuser, but + * snapshot replay to POST /livewire/update bypasses that gate. Without + * this check, a low-privilege user with a valid snapshot could invoke + * check_locations() and read cross-tenant FMCS-mismatch data through + * the render payload. + */ + public function boot(): void + { + if (! auth()->user()?->isSuperUser()) { + abort(403); + } + } + public function check_locations() { $this->mismatched = Helper::test_locations_fmcs(false); diff --git a/app/Livewire/OauthClients.php b/app/Livewire/OauthClients.php index 5fafe2fa8d..90f6013b65 100644 --- a/app/Livewire/OauthClients.php +++ b/app/Livewire/OauthClients.php @@ -7,10 +7,17 @@ use Illuminate\Support\Facades\Log; use Laravel\Passport\Client; use Laravel\Passport\ClientRepository; use Laravel\Passport\Token; +use Livewire\Attributes\Locked; use Livewire\Component; class OauthClients extends Component { + /** + * Locked so a client-side snapshot replay cannot flip the section from + * an admin context (oauth-clients) into a lower-privilege context + * (authorized-applications) to bypass the boot() authorization gate. + */ + #[Locked] public string $section = 'all'; public $name; @@ -32,6 +39,25 @@ class OauthClients extends Component } } + /** + * Livewire boot() fires on the initial mount AND on every subsequent + * POST /livewire/update from the same component instance. Route-level + * middleware (superuser gate on /admin/oauth) protects the initial page + * render but NOT snapshot replays that arrive at /livewire/update + * carrying a valid signed snapshot of this component. Enforce the same + * authorization here so a low-privilege attacker who obtains a signed + * snapshot (e.g. from a shared admin page, a proxied response, a + * partially-leaked prior session) cannot invoke createClient / + * deleteAuthorizedApplication under their own session and mint / + * revoke admin-scoped tokens. + */ + public function boot(): void + { + if ($this->showOauthClients() && ! auth()->user()?->isSuperUser()) { + abort(403); + } + } + public function showOauthClients(): bool { return in_array($this->section, ['all', 'oauth-clients'], true); @@ -97,6 +123,15 @@ class OauthClients extends Component public function createClient(): void { + // Defense in depth on top of boot(). createClient is only reachable + // from the admin OAuth-clients management surface, which is + // superuser-gated at the route level. Snapshot replay to + // POST /livewire/update can reach here regardless of route gating, + // so re-check the same authorization here explicitly. + if (! auth()->user()?->isSuperUser()) { + abort(403); + } + $this->validate([ 'name' => 'required|string|max:255', 'redirect' => 'required|url|max:255', @@ -127,10 +162,21 @@ class OauthClients extends Component public function deleteAuthorizedApplication(int $clientId): void { - $revokedTokenCount = DB::table('oauth_access_tokens') + // Only revoke tokens the caller actually owns. Superusers may revoke + // any authorized-application entry (matches their admin-surface + // reach). Anyone else is limited to their own access tokens for the + // named client. Prevents a snapshot replay from calling this method + // and revoking another user's active tokens (denial of service on + // legitimate integrations). + $query = DB::table('oauth_access_tokens') ->where('client_id', $clientId) - ->where('revoked', false) - ->update(['revoked' => true]); + ->where('revoked', false); + + if (! auth()->user()?->isSuperUser()) { + $query->where('user_id', auth()->id()); + } + + $revokedTokenCount = $query->update(['revoked' => true]); if ($revokedTokenCount > 0) { session()->flash('success', trans('admin/settings/message.oauth.token_deleted')); @@ -142,6 +188,14 @@ class OauthClients extends Component public function editClient(Client $editClientId): void { + // Only the client owner or a superuser may pre-fill the edit modal. + // Without this check, snapshot replay could load any client's name + // and redirect URI into the component's public props, exposing them + // via the next render() response. + if (! auth()->user()?->isSuperUser() && $editClientId->user_id != auth()->id()) { + abort(403); + } + $this->editName = $editClientId->name; $this->editRedirect = $editClientId->redirect; diff --git a/app/Livewire/PersonalAccessTokens.php b/app/Livewire/PersonalAccessTokens.php index 2acecdd2e4..725232aeec 100644 --- a/app/Livewire/PersonalAccessTokens.php +++ b/app/Livewire/PersonalAccessTokens.php @@ -13,6 +13,19 @@ class PersonalAccessTokens extends Component protected $listeners = ['openModal' => 'autoFocusModalEvent']; + /** + * Route-level middleware on /account/api requires the self.api gate, + * but snapshot replay to POST /livewire/update bypasses that. Re-check + * the same gate here so a user without self.api cannot mint a PAT by + * replaying a valid snapshot obtained elsewhere. + */ + public function boot(): void + { + if (! auth()->user()?->can('self.api')) { + abort(403); + } + } + // this is just an annoying thing to make the modal input autofocus public function autoFocusModalEvent(): void { diff --git a/app/Livewire/SlackSettingsForm.php b/app/Livewire/SlackSettingsForm.php index 1462eaf4cd..6a3d4bdf60 100644 --- a/app/Livewire/SlackSettingsForm.php +++ b/app/Livewire/SlackSettingsForm.php @@ -59,6 +59,21 @@ class SlackSettingsForm extends Component ]; } + /** + * Route-level middleware on the notifications settings page requires + * superuser, but snapshot replay to POST /livewire/update bypasses + * that gate. Without this check, a low-privilege user with a valid + * snapshot could invoke testWebhook / clearSettings / submit and + * mutate global webhook configuration or exfiltrate the configured + * webhook_endpoint / channel through the render payload. + */ + public function boot(): void + { + if (! auth()->user()?->isSuperUser()) { + abort(403); + } + } + public function mount() { $this->webhook_text = [ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 70fb393d27..92c87e8e72 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -466,6 +466,11 @@ class UserFactory extends Factory return $this->appendPermission(['users.edit' => '1']); } + public function selfApi() + { + return $this->appendPermission(['self.api' => '1']); + } + public function deleteUsers() { return $this->appendPermission(['users.delete' => '1']); diff --git a/tests/Feature/Livewire/OauthClientsAuthorizationTest.php b/tests/Feature/Livewire/OauthClientsAuthorizationTest.php new file mode 100644 index 0000000000..7f89440511 --- /dev/null +++ b/tests/Feature/Livewire/OauthClientsAuthorizationTest.php @@ -0,0 +1,157 @@ +actingAs(User::factory()->superuser()->create()); + + Livewire::test(OauthClients::class, ['section' => 'oauth-clients']) + ->assertStatus(200); + } + + public function test_non_superuser_cannot_mount_the_admin_oauth_clients_section() + { + $this->actingAs(User::factory()->create()); + + Livewire::test(OauthClients::class, ['section' => 'oauth-clients']) + ->assertStatus(403); + } + + public function test_non_superuser_cannot_replay_create_client_via_authorized_applications_section() + { + // The section property is Locked so a client can't rewrite it from + // authorized-applications to oauth-clients to slip past boot(). + // Even if boot() lets them through under a benign section, the + // per-method superuser check on createClient blocks the mint. + $this->actingAs(User::factory()->create()); + + Livewire::test(OauthClients::class, ['section' => 'authorized-applications']) + ->set('name', 'attacker-client') + ->set('redirect', 'http://attacker.test/callback') + ->call('createClient') + ->assertStatus(403); + + $this->assertDatabaseMissing('oauth_clients', ['name' => 'attacker-client']); + } + + public function test_non_superuser_cannot_replay_delete_authorized_application_for_others_tokens() + { + $victim = User::factory()->create(); + $client = Client::create([ + 'user_id' => $victim->id, + 'name' => 'Victim App', + 'secret' => 'secret', + 'provider' => null, + 'redirect' => 'http://victim.test/callback', + 'personal_access_client' => false, + 'password_client' => false, + 'revoked' => false, + ]); + $victimTokenId = 'victim-token-'.uniqid(); + \DB::table('oauth_access_tokens')->insert([ + 'id' => $victimTokenId, + 'user_id' => $victim->id, + 'client_id' => $client->id, + 'name' => 'victim', + 'scopes' => '[]', + 'revoked' => false, + 'created_at' => now(), + 'updated_at' => now(), + 'expires_at' => now()->addYear(), + ]); + + // Attacker acts under the authorized-applications section (which a + // non-superuser IS allowed to access, since it's the account/api + // surface). Replay attempts to revoke the victim's active token. + $this->actingAs(User::factory()->create()); + + Livewire::test(OauthClients::class, ['section' => 'authorized-applications']) + ->call('deleteAuthorizedApplication', $client->id); + + $this->assertDatabaseHas('oauth_access_tokens', [ + 'id' => $victimTokenId, + 'revoked' => false, + ]); + } + + public function test_superuser_can_still_revoke_any_authorized_application() + { + $victim = User::factory()->create(); + $client = Client::create([ + 'user_id' => $victim->id, + 'name' => 'Victim App', + 'secret' => 'secret', + 'provider' => null, + 'redirect' => 'http://victim.test/callback', + 'personal_access_client' => false, + 'password_client' => false, + 'revoked' => false, + ]); + $victimTokenId = 'victim-token-'.uniqid(); + \DB::table('oauth_access_tokens')->insert([ + 'id' => $victimTokenId, + 'user_id' => $victim->id, + 'client_id' => $client->id, + 'name' => 'victim', + 'scopes' => '[]', + 'revoked' => false, + 'created_at' => now(), + 'updated_at' => now(), + 'expires_at' => now()->addYear(), + ]); + + $this->actingAs(User::factory()->superuser()->create()); + + Livewire::test(OauthClients::class, ['section' => 'oauth-clients']) + ->call('deleteAuthorizedApplication', $client->id); + + $this->assertDatabaseHas('oauth_access_tokens', [ + 'id' => $victimTokenId, + 'revoked' => true, + ]); + } + + public function test_non_superuser_cannot_replay_edit_client_to_read_other_client_details() + { + $victim = User::factory()->create(); + $client = Client::create([ + 'user_id' => $victim->id, + 'name' => 'Victim App', + 'secret' => 'secret', + 'provider' => null, + 'redirect' => 'http://victim.test/callback', + 'personal_access_client' => false, + 'password_client' => false, + 'revoked' => false, + ]); + + $this->actingAs(User::factory()->create()); + + $component = Livewire::test(OauthClients::class, ['section' => 'authorized-applications']) + ->call('editClient', $client->id) + ->assertStatus(403); + + $this->assertSame('', (string) $component->get('editName')); + $this->assertSame('', (string) $component->get('editRedirect')); + } +} diff --git a/tests/Feature/Livewire/PersonalAccessTokensTest.php b/tests/Feature/Livewire/PersonalAccessTokensTest.php index fb1b759ac1..6fa5d99a83 100644 --- a/tests/Feature/Livewire/PersonalAccessTokensTest.php +++ b/tests/Feature/Livewire/PersonalAccessTokensTest.php @@ -11,7 +11,7 @@ class PersonalAccessTokensTest extends TestCase { public function test_the_component_can_render() { - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->selfApi()->create()); Livewire::test(PersonalAccessTokens::class) ->assertStatus(200); @@ -19,11 +19,30 @@ class PersonalAccessTokensTest extends TestCase public function test_create_token_validation_fails_without_name() { - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->selfApi()->create()); Livewire::test(PersonalAccessTokens::class) ->set('name', '') ->call('createToken') ->assertHasErrors(['name' => 'required']); } + + /** + * Regression for the Livewire snapshot-replay class of vuln reported + * by PizzaStev3 (2026-07-31). Without the boot() gate, a user who was + * blocked from /account/api by the self.api middleware could still + * mint a PAT by replaying a valid signed snapshot of the + * PersonalAccessTokens component to POST /livewire/update. + * + * boot() fires on both the initial mount AND every subsequent + * /livewire/update, so a 403 at mount here implies the same 403 on any + * replayed action call. + */ + public function test_user_without_self_api_permission_cannot_mount_or_replay_the_component() + { + $this->actingAs(User::factory()->create()); + + Livewire::test(PersonalAccessTokens::class) + ->assertStatus(403); + } }