mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 03:06:23 +00:00
Livewire: Fixed FD-56800 - Harden livewire mount and boot methods against snapshot replays
This commit is contained in:
@ -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')
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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']);
|
||||
|
||||
157
tests/Feature/Livewire/OauthClientsAuthorizationTest.php
Normal file
157
tests/Feature/Livewire/OauthClientsAuthorizationTest.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\OauthClients;
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Client;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Regression coverage for the Livewire snapshot-replay authorization bypass
|
||||
* reported by PizzaStev3 (2026-07-31). Route-level middleware on /admin/oauth
|
||||
* enforces superuser but a POST /livewire/update with a valid signed snapshot
|
||||
* of the oauth-clients component reached the component's methods regardless.
|
||||
*
|
||||
* Fix: boot() gates the admin section, individual sensitive methods
|
||||
* (createClient, deleteAuthorizedApplication, editClient) re-check, and
|
||||
* the section property is #[Locked] so a client-side snapshot cannot flip
|
||||
* from admin section to user section to slip past boot().
|
||||
*/
|
||||
class OauthClientsAuthorizationTest extends TestCase
|
||||
{
|
||||
public function test_superuser_can_mount_the_admin_oauth_clients_section()
|
||||
{
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user