3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00

Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2026-07-31 15:58:39 +01:00
18 changed files with 539 additions and 6 deletions

View File

@ -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')

View File

@ -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;

View File

@ -836,6 +836,19 @@ class Importer extends Component
}
$this->headerRow = $this->activeFile->header_row;
// header_row is populated by the initial upload path but can be null for
// legacy imports created before that column was persisted, or for rows
// where a background job never wrote it. Without this guard the foreach
// below explodes with "foreach() argument must be of type array|object,
// null given" and the wizard is unrecoverable.
if (! is_array($this->headerRow) || $this->headerRow === []) {
$this->message = trans('admin/hardware/message.import.header_row_missing');
$this->message_type = 'danger';
return;
}
$this->typeOfImport = $this->activeFile->import_type;
$this->field_map = null;

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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
{

View File

@ -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 = [

View File

@ -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']);

View File

@ -76,6 +76,7 @@ return [
'file_already_deleted' => 'The file selected was already deleted',
'file_missing_on_disk' => 'The file for this import is no longer on disk. It may have been deleted outside of Snipe-IT. Delete this entry and re-upload the file to try again.',
'file_empty' => 'This file has no data rows. Nothing can be imported from it.',
'header_row_missing' => 'This file does not have a recognized header row. Delete this entry and re-upload the file to try again.',
'header_row_has_malformed_characters' => 'One or more attributes in the header row contain malformed UTF-8 characters',
'content_row_has_malformed_characters' => 'One or more attributes in the first row of content contain malformed UTF-8 characters',
'transliterate_failure' => 'Transliteration from :encoding to UTF-8 failed due to invalid characters in input',

View File

@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\AdminPersonalAccessTokens;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Regression coverage for the Livewire snapshot-replay authorization bypass
* reported by PizzaStev3 (2026-07-31). AdminPersonalAccessTokens' render()
* enumerates every user's personal access tokens (name, expiration,
* associated client, owning user). Route-level middleware on /admin/oauth
* (superuser) protects the initial page render but not snapshot replays to
* /livewire/update. boot() gate now catches both surfaces.
*/
class AdminPersonalAccessTokensAuthorizationTest extends TestCase
{
public function test_superuser_can_mount()
{
$this->actingAs(User::factory()->superuser()->create());
Livewire::test(AdminPersonalAccessTokens::class)
->assertStatus(200);
}
public function test_non_superuser_cannot_mount_or_replay()
{
$this->actingAs(User::factory()->create());
Livewire::test(AdminPersonalAccessTokens::class)
->assertStatus(403);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\CustomFieldSetDefaultValuesForModel;
use App\Models\AssetModel;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Regression coverage for the Livewire snapshot-replay authorization bypass
* reported by PizzaStev3 (2026-07-31). The component renders custom-field
* default values for an AssetModel identified by model_id. Route-level
* middleware on the model create/edit pages requires models.edit, but
* snapshot replay to /livewire/update bypassed that gate. boot() gate
* now requires AssetModel update permission on both mount and any replayed
* action.
*/
class CustomFieldSetDefaultValuesForModelAuthorizationTest extends TestCase
{
public function test_user_with_models_edit_permission_can_mount()
{
$model = AssetModel::factory()->create();
$this->actingAs(User::factory()->editAssetModels()->create());
Livewire::test(CustomFieldSetDefaultValuesForModel::class, ['model_id' => $model->id])
->assertStatus(200);
}
public function test_user_without_models_edit_permission_cannot_mount_or_replay()
{
$model = AssetModel::factory()->create();
$this->actingAs(User::factory()->create());
Livewire::test(CustomFieldSetDefaultValuesForModel::class, ['model_id' => $model->id])
->assertStatus(403);
}
}

View File

@ -355,6 +355,29 @@ class ImporterTest extends TestCase
->assertSet('activeFileRowCount', 0);
}
/**
* Regression for Rollbar: selectFile() foreach()-on-null when the Import
* row was persisted with header_row = null (legacy imports, or a background
* job that never wrote the column). Previously exploded with
* "foreach() argument must be of type array|object, null given" at the
* headerRow loop. The guard now short-circuits with a translated error.
*/
public function test_selecting_a_file_with_null_header_row_shows_error_and_does_not_crash(): void
{
$user = User::factory()->canImport()->create();
$import = Import::factory()->create([
'created_by' => $user->id,
'header_row' => null,
]);
$this->writeFakeImportFile($import, "asset tag\nAH-1\n");
Livewire::actingAs($user)
->test(Importer::class)
->call('selectFile', $import->id)
->assertSet('message_type', 'danger')
->assertSet('message', trans('admin/hardware/message.import.header_row_missing'));
}
public function test_next_step_from_type_selection_advances_when_type_is_set(): void
{
Storage::fake();

View File

@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\LdapSettings;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Regression coverage for the Livewire snapshot-replay authorization bypass
* reported by PizzaStev3 (2026-07-31). LdapSettings had an abort_unless
* check in mount() which only fires on the initial page render. Snapshot
* replay to /livewire/update went through hydrate() -> action methods
* without re-entering mount(). Now duplicated into boot() which fires on
* every Livewire request.
*/
class LdapSettingsAuthorizationTest extends TestCase
{
public function test_superadmin_can_mount()
{
$this->actingAs(User::factory()->firstAdmin()->create());
Livewire::test(LdapSettings::class)
->assertStatus(200);
}
public function test_non_superadmin_cannot_mount_or_replay()
{
$this->actingAs(User::factory()->create());
Livewire::test(LdapSettings::class)
->assertStatus(403);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\LocationScopeCheck;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Regression coverage for the Livewire snapshot-replay authorization bypass
* reported by PizzaStev3 (2026-07-31). LocationScopeCheck::check_locations()
* surfaces cross-tenant FMCS-mismatch data. Route-level middleware on
* /admin/settings (superuser) protects the initial page render but not
* snapshot replays to /livewire/update. boot() gate now catches both.
*/
class LocationScopeCheckAuthorizationTest extends TestCase
{
public function test_superuser_can_mount()
{
$this->actingAs(User::factory()->superuser()->create());
Livewire::test(LocationScopeCheck::class)
->assertStatus(200);
}
public function test_non_superuser_cannot_mount_or_replay()
{
$this->actingAs(User::factory()->create());
Livewire::test(LocationScopeCheck::class)
->assertStatus(403);
}
}

View 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'));
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\SlackSettingsForm;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Regression coverage for the Livewire snapshot-replay authorization bypass
* reported by PizzaStev3 (2026-07-31). SlackSettingsForm had no
* per-request authorization and exposed webhook mutation methods
* (testWebhook, clearSettings, submit) plus render()-time disclosure of
* the configured webhook_endpoint/channel. boot() gate now blocks both
* mount and any replay under a non-superuser session.
*/
class SlackSettingsFormAuthorizationTest extends TestCase
{
public function test_superuser_can_mount()
{
$this->actingAs(User::factory()->superuser()->create());
Livewire::test(SlackSettingsForm::class)
->assertStatus(200);
}
public function test_non_superuser_cannot_mount_or_replay()
{
$this->actingAs(User::factory()->create());
Livewire::test(SlackSettingsForm::class)
->assertStatus(403);
}
}