Warenwirtschaft: OPT-9 AuthZ/CSRF-Härtung (Audit M-2, M-3, M-6, M-7, M-10)

- M-2: Produktionen buchen Bestand — StoreProductionRequest verlangt
  jetzt admin >= 7 (wie manuelle Bestandsbewegungen); create/edit/copy
  im Controller gegated, UI-Buttons für niedrigere Stufen ausgeblendet.
- M-3: Ausschuss-/Bewegungs-/Produktions-Buttons nur noch für
  Berechtigte sichtbar (keine toten Formulare mehr).
- M-6: Zustandsändernde GET-Routen umgestellt — stock-entries/copy
  jetzt POST mit CSRF; /admin/product/delete jetzt DELETE-Formulare
  (Produktliste + Rezeptur-Zeile im Produktformular).
- M-7: ProductController::imageAttribute whitelistet Spalten
  ('active', 'pos') + numerische Werte, sonst 400 — kein beliebiger
  Spalten-Write per URL mehr.
- M-10: URL-Felder (Lieferant, Verpackungsartikel, INCI-Lieferanten-
  Repeater) validieren url:http,https — keine speicherbaren
  javascript:-Links mehr.

Tests: ProductionHardeningTest +2; drei bestehende Tests an neue
Regeln angepasst (admin-Level 7 für Produktions-Formular, POST-Copy).
Suite: 231 grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 12:55:11 +00:00
parent fa44af0a1f
commit 9ed47c02f3
19 changed files with 104 additions and 41 deletions

View file

@ -33,8 +33,13 @@ class ProductionController extends Controller
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
// Audit M-2: Produktionen buchen Bestand — nur admin >= 7.
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id')
?? Location::query()->where('active', true)->first()?->id;
@ -101,8 +106,11 @@ class ProductionController extends Controller
]);
}
public function edit(Production $production): View
public function edit(Production $production): View|RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$production->load([
'product',
'location',
@ -161,8 +169,11 @@ class ProductionController extends Controller
return redirect()->route('admin.inventory.productions.show', $production);
}
public function copy(Production $production): View
public function copy(Production $production): View|RedirectResponse
{
if (! auth()->user()->isAdmin()) {
return redirect()->route('home');
}
$production->load([
'product',
'productionIngredients.ingredient',

View file

@ -222,8 +222,13 @@ class ProductController extends Controller
public function imageAttribute($product_id, $attr, $val = false)
{
// Audit M-7: nur erlaubte Spalten — $attr kommt aus der URL und würde
// sonst die $fillable-Whitelist per dynamischer Property umgehen.
if (! in_array($attr, ['active', 'pos'], true) || ! is_numeric($val)) {
abort(400);
}
if (is_numeric($val) && $val < 0) {
if ($val < 0) {
$val = 0;
}

View file

@ -24,7 +24,7 @@ class StorePackagingItemRequest extends FormRequest
'category' => ['required', Rule::in(['packaging', 'shipping'])],
'weight_grams' => ['nullable', 'numeric', 'min:0'],
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
'url' => ['nullable', 'string', 'max:2048'],
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'product_id' => ['nullable', 'integer', 'exists:products,id'],
'active' => ['sometimes', 'boolean'],
];

View file

@ -9,7 +9,9 @@ class StoreProductionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
// Audit M-2: Produktionen buchen Produktbestand — gleiche Schranke
// wie manuelle Bestandsbewegungen (admin >= 7).
return (bool) $this->user()?->isAdmin();
}
/**

View file

@ -18,10 +18,10 @@ class StoreSupplierRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'string', 'max:2048'],
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'order_method' => ['nullable', 'in:email,online_shop'],
'order_email' => ['nullable', 'email', 'max:255'],
'order_url' => ['nullable', 'string', 'max:2048'],
'order_url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'delivery_time' => ['nullable', 'string', 'max:255'],
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
'contact_person' => ['nullable', 'string', 'max:255'],

View file

@ -24,7 +24,7 @@ class UpdatePackagingItemRequest extends FormRequest
'category' => ['required', Rule::in(['packaging', 'shipping'])],
'weight_grams' => ['nullable', 'numeric', 'min:0'],
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
'url' => ['nullable', 'string', 'max:2048'],
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'product_id' => ['nullable', 'integer', 'exists:products,id'],
'active' => ['sometimes', 'boolean'],
];

View file

@ -18,10 +18,10 @@ class UpdateSupplierRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'string', 'max:2048'],
'url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'order_method' => ['nullable', 'in:email,online_shop'],
'order_email' => ['nullable', 'email', 'max:255'],
'order_url' => ['nullable', 'string', 'max:2048'],
'order_url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'delivery_time' => ['nullable', 'string', 'max:255'],
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
'contact_person' => ['nullable', 'string', 'max:255'],

View file

@ -30,7 +30,7 @@ class StoreIngredientRequest extends FormRequest
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
'suppliers' => ['nullable', 'array'],
'suppliers.*.id' => ['nullable', 'integer', 'exists:suppliers,id'],
'suppliers.*.url' => ['nullable', 'string', 'max:2048'],
'suppliers.*.url' => ['nullable', 'string', 'url:http,https', 'max:2048'],
'storage_room_id' => ['nullable', 'integer', 'exists:storage_rooms,id'],
'storage_shelf_id' => ['nullable', 'integer', 'exists:storage_shelves,id'],
'storage_slot_id' => ['nullable', 'integer', 'exists:storage_slots,id'],

View file

@ -132,10 +132,10 @@
<button type="button" class="btn icon-btn btn-sm btn-outline-danger js-ps-move"
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="out"
title="{{ __('Ausgang buchen') }}"><span class="fas fa-minus"></span></button>
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
class="btn btn-sm btn-outline-secondary">
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
@endif
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
class="btn btn-sm btn-outline-secondary">
<span class="fas fa-flask mr-1"></span>{{ __('Produzieren') }}</a>
</div>
</td>
</tr>

View file

@ -10,9 +10,11 @@
<p class="wawi-page-head__subtitle">{{ __('Erfasste Produktionschargen inkl. Rohstoff- und Verpackungsverbrauch') }}</p>
</div>
<div class="wawi-page-head__actions">
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-primary btn-sm">
<span class="fas fa-plus mr-1"></span>{{ __('Neue Produktion') }}
</a>
@if (Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-primary btn-sm">
<span class="fas fa-plus mr-1"></span>{{ __('Neue Produktion') }}
</a>
@endif
</div>
</div>
@ -36,13 +38,13 @@
<a href="{{ route('admin.inventory.productions.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
<span class="far fa-eye"></span>
</a>
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
<span class="far fa-edit"></span>
</a>
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
<span class="far fa-copy"></span>
</a>
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
<span class="far fa-edit"></span>
</a>
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
<span class="far fa-copy"></span>
</a>
<form action="{{ route('admin.inventory.productions.destroy', $row) }}" method="post" class="d-inline"
onsubmit="return confirm(@json(__('Produktion wirklich löschen? Der gebuchte Produktbestand wird zurückgebucht und der Rohstoffverbrauch wieder freigegeben.')));">
@csrf

View file

@ -46,9 +46,10 @@
</a>
@endif
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-entries.copy', $row) }}" class="btn icon-btn btn-sm btn-info" title="{{ __('Duplizieren') }}">
<span class="far fa-copy"></span>
</a>
<form action="{{ route('admin.inventory.stock-entries.copy', $row) }}" method="post" class="d-inline">
@csrf
<button type="submit" class="btn icon-btn btn-sm btn-info" title="{{ __('Duplizieren') }}"><span class="far fa-copy"></span></button>
</form>
@endif
</td>
<td data-sort="{{ $row->status === 'pending' ? 0 : 1 }}">

View file

@ -22,7 +22,10 @@
<div class="wawi-page-head__actions">
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück zur Liste') }}</a>
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-entries.copy', $model) }}" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</a>
<form action="{{ route('admin.inventory.stock-entries.copy', $model) }}" method="post" class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
</form>
@endif
@if(Auth::user()->isAdmin() && $model->isPending())
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>

View file

@ -637,10 +637,12 @@
<td class="align-middle pi-effective text-right small text-muted">—</td>
<td class="align-middle">
@if ($product->id)
<a class="text-danger"
href="{{ route('admin_product_delete', [$product->id, 'ingredient', $ingredient->id]) }}"
onclick="return confirm('{{ __('Eintrag entfernen?') }}');"><i
class="far fa-trash-alt"></i></a>
<form method="post" action="{{ route('admin_product_delete', [$product->id, 'ingredient', $ingredient->id]) }}"
class="d-inline" onsubmit="return confirm('{{ __('Eintrag entfernen?') }}');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-link text-danger p-0 border-0 align-baseline"><i class="far fa-trash-alt"></i></button>
</form>
@else
<a class="text-danger ingredient-row-remove" href="#"
title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a>

View file

@ -97,9 +97,12 @@
<td><a class="text-info" href="{{ route('admin_product_copy', [$value->id]) }}"
onclick="return confirm('{{ __('Eintrag kopieren?') }}');"><i
class="far fa-copy"></i></a> &nbsp;
<a class="text-danger" href="{{ route('admin_product_delete', [$value->id]) }}"
onclick="return confirm('{{ __('Really delete entry?') }}');"><i
class="far fa-trash-alt"></i></a>
<form method="post" action="{{ route('admin_product_delete', [$value->id]) }}" class="d-inline"
onsubmit="return confirm('{{ __('Really delete entry?') }}');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-link text-danger p-0 border-0 align-baseline"><i class="far fa-trash-alt"></i></button>
</form>
</td>
</tr>
@endforeach

View file

@ -323,7 +323,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::post('stock-disposals', [InventoryStockDisposalController::class, 'store'])->name('stock-disposals.store');
Route::delete('stock-disposals/{stock_disposal}', [InventoryStockDisposalController::class, 'destroy'])->name('stock-disposals.destroy');
Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive');
Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
Route::post('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
Route::resource('stock-entries', InventoryStockEntryController::class);
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']);
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
@ -337,7 +337,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::post('/admin/product/store', [ProductController::class, 'store'])->name('admin_product_store');
Route::get('/admin/product/edit/{id}', [ProductController::class, 'edit'])->name('admin_product_edit');
Route::get('/admin/product/copy/{id}', [ProductController::class, 'copy'])->name('admin_product_copy');
Route::get('/admin/product/delete/{id}/{do?}/{did?}', [ProductController::class, 'delete'])->name('admin_product_delete');
Route::delete('/admin/product/delete/{id}/{do?}/{did?}', [ProductController::class, 'delete'])->name('admin_product_delete');
// products images
Route::post('/admin/product/image/upload', [ProductController::class, 'imageUpload'])->name('admin_product_image_upload');
Route::get('/admin/product/image/delete/{image_id}/{product_id}', [ProductController::class, 'imageDelete'])->name('admin_product_image_delete');

View file

@ -170,7 +170,7 @@ test('produkt-kopie uebernimmt beide rezepturen', function () {
test('produktions-formular zeigt nur aktive produkte', function () {
p51EnsureCountry();
Location::factory()->create(['name' => 'Köln']);
$user = p51MakeUser(1);
$user = p51MakeUser(7);
Product::query()->create(['name' => 'P51-AKTIV-PROD', 'title' => 'P51-AKTIV-PROD', 'active' => true]);
Product::query()->create(['name' => 'P51-INAKTIV-PROD', 'title' => 'P51-INAKTIV-PROD', 'active' => false]);

View file

@ -176,7 +176,7 @@ test('set kann nicht produziert werden', function () {
test('produktions-formular zeigt keine sets', function () {
p51EnsureCountryForSet();
Location::factory()->create(['name' => 'Köln']);
$user = setMakeUser(1);
$user = setMakeUser(7);
setMakeProduct('SET-FORM-EINZEL');
$set = setMakeProduct('SET-FORM-SET');

View file

@ -158,3 +158,37 @@ test('ausschuss ueber restbestand wird abgelehnt (M-1)', function () {
$response->assertSessionHasErrors('quantity');
expect(StockDisposal::query()->count())->toBe(0);
});
test('copyreader darf keine produktion buchen (M-2)', function () {
$viewer = User::query()->create([
'email' => uniqid('ph_', true).'@test.example',
'password' => bcrypt('password'),
]);
$viewer->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
$this->actingAs($viewer->fresh(), 'user');
$product = Product::query()->create(['name' => 'PH-M2', 'title' => 'PH-M2', 'active' => true, 'no_recipe_required' => true]);
$location = Location::factory()->create();
$this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-15',
'quantity' => 5,
'outputs' => [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
])->assertForbidden();
expect(Production::query()->count())->toBe(0);
});
test('javascript-url wird bei lieferanten abgelehnt (M-10)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'PH-XSS-Lieferant',
'url' => 'javascript:alert(1)',
])->assertSessionHasErrors('url');
expect(Supplier::query()->where('name', 'PH-XSS-Lieferant')->exists())->toBeFalse();
});

View file

@ -169,7 +169,7 @@ test('copy creates pending duplicate without receiving data', function () {
$this->actingAs($admin, 'user');
$this->get(route('admin.inventory.stock-entries.copy', $source))
$this->post(route('admin.inventory.stock-entries.copy', $source))
->assertRedirect();
$copy = StockEntry::query()->where('status', 'pending')->latest('id')->firstOrFail();
@ -198,6 +198,6 @@ test('copy is blocked for non admin', function () {
$this->actingAs($copyreader->fresh(), 'user');
$this->get(route('admin.inventory.stock-entries.copy', $source))
$this->post(route('admin.inventory.stock-entries.copy', $source))
->assertRedirect(route('home'));
});