Laravel-Sicherheit beginnt vor dem Controller

Wie Mandantengrenzen, Policies, Zustandswechsel, Queues und negative Tests zu überprüfbaren Sicherheitsinvarianten werden.

Laravel schützt viele technische Standardfälle. Es rotiert Sessions, prüft CSRF, maskiert Blade-Ausgaben und bindet Query-Werte. Die Regeln eines Produkts kennt das Framework jedoch nicht: Welcher Benutzer darf welche Rechnung sehen? Darf eine Freigabe nachträglich zurückgenommen werden? Was geschieht, wenn ein Job erst nach einem Rollenwechsel ausgeführt wird?

Die relevante Sicherheitseigenschaft lautet nicht „Die Route hat Auth-Middleware“, sondern „Kein Benutzer kann Daten oder Aktionen außerhalb seines Mandanten und seiner aktuellen Berechtigung erreichen“.

Diese Eigenschaft muss in Datenmodell, Anwendungscode und Tests dieselbe Bedeutung besitzen. Dann wird Sicherheit überprüfbar, statt von der Erinnerung einzelner Entwickler abzuhängen.

Vom Feature zur Sicherheitsinvariante

Ein Feature beschreibt meist den gewünschten Erfolg. Eine Sicherheitsinvariante ergänzt, was selbst bei manipulierten Requests, veralteten Jobs und parallelen Abläufen niemals passieren darf.

Produktfunktion Denkbarer Missbrauch Sicherheitsinvariante Nachweis
Rechnung anzeigen fremde ID einsetzen nur aktueller Mandant und berechtigte Rolle Cross-Tenant-Feature-Test
Angebot freigeben eigenen Entwurf selbst genehmigen Vier-Augen-Regel und erlaubter Zustand Policy- und Zustands-Test
Export erzeugen Rolle nach dem Start entzogen Berechtigung bei Job-Ausführung erneut prüfen Queue-Integrationstest
Dokument laden alten signierten Link verwenden aktuelle Policy gilt auch beim Download Test nach Rechteentzug
Webhook buchen Event wiederholen Provider-ID bewirkt höchstens eine Mutation Replay- und Concurrency-Test

Sicherheitsgrenzen einer Laravel-Plattform

Die Anwendung ist nicht nur über Controller erreichbar. Jede Verbindung zum Kern benötigt eine explizite Vertrauensentscheidung und einen negativen Test.

Die Mandantengrenze ist eine Systemeigenschaft

Eine tenant_id im Controller reicht nicht. Dieselbe Grenze gilt an jeder Stelle, an der Daten geladen, zwischengespeichert oder ausgeliefert werden:

  • HTTP- und API-Routen
  • Policies und verschachtelte Beziehungen
  • Queue-Jobs, Commands und Scheduler
  • Exporte, Downloads und temporäre Links
  • Cache-Keys, Suche und Broadcast Channels
  • administrative Werkzeuge

Einfaches Route Model Binding löst ein Objekt auf, autorisiert es aber nicht. Die Policy beschreibt deshalb die vollständige fachliche Entscheidung:

final class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->tenant_id === $invoice->tenant_id
            && $user->is_active
            && $user->can('invoice.read');
    }
}

public function show(Invoice $invoice): InvoiceResource
{
    Gate::authorize('view', $invoice);

    return InvoiceResource::make($invoice);
}

Der wichtige Test bestätigt nicht nur den erlaubten Zugriff. Er versucht bewusst, eine gültige ID aus einem anderen Mandanten zu verwenden:

it('does not expose an invoice from another tenant', function () {
    $user = User::factory()->for($tenantA)->create();
    $invoice = Invoice::factory()->for($tenantB)->create();

    $this->actingAs($user)
        ->getJson(route('invoices.show', $invoice))
        ->assertForbidden();
});

Autorisierung benennt Aktionen, nicht nur Rollen

„Admin“ oder „Editor“ beschreibt selten ausreichend, was fachlich erlaubt ist. Gute Policies sprechen die Sprache des Produkts: approve, cancel, refund, publish oder export.

Eine Entscheidung kann gleichzeitig von mehreren Bedingungen abhängen:

  • Gehört das Objekt zum aktuellen Mandanten?
  • Besitzt der Benutzer genau diese Fähigkeit?
  • Darf er seine eigene Änderung bestätigen?
  • Befindet sich das Objekt im erlaubten Zustand?
  • Ist eine Frist oder Betragsgrenze überschritten?

Ein ausgeblendeter Button beantwortet keine dieser Fragen. Die Oberfläche erklärt eine Entscheidung, die serverseitige Policy erzwingt sie.

Zustandswechsel sind der eigentliche Angriffspunkt

Viele Business-Logic-Fehler entstehen zwischen zwei gültigen Zuständen. Ein Auftrag darf bearbeitbar sein, aber nicht direkt von draft zu paid springen. Eine Freigabe darf nur aus pending erfolgen und nicht vom Ersteller selbst.

Von Aktion Nach Zusätzliche Bedingung
draft einreichen pending Pflichtfelder vollständig
pending freigeben approved andere berechtigte Person
approved ausführen completed unveränderte Freigabeversion
completed korrigieren neuer Vorgang keine stille Rückmutation

Die Zustandsprüfung und Mutation gehören in dieselbe atomare Operation:

DB::transaction(function () use ($invoice, $user): void {
    $locked = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);

    Gate::forUser($user)->authorize('approve', $locked);
    throw_unless($locked->status === InvoiceStatus::Pending, DomainException::class);

    $locked->update([
        'status' => InvoiceStatus::Approved,
        'approved_by' => $user->id,
        'approved_at' => now(),
    ]);
});

lockForUpdate() ist dabei kein allgemeiner Sicherheitsschalter. Es schützt diese Zeile nur innerhalb einer passenden Datenbanktransaktion. Fachliche Regeln und Datenbank-Constraints bleiben zusätzlich notwendig.

Eingaben erreichen das System auf vielen Wegen

Form Requests sind eine gute HTTP-Grenze, aber Daten kommen auch aus Webhooks, CSV-Importen, Queue-Payloads, CLI-Kommandos und internen Tools. Jede Quelle bleibt nicht vertrauenswürdig, bis Struktur und Beziehung zum aktuellen Kontext geprüft wurden.

final class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'timezone' => ['required', Rule::in(DateTimeZone::listIdentifiers())],
        ];
    }
}

// Nur validierte Felder dieses Anwendungsfalls erreichen Eloquent.
$user->update($request->safe()->only(['name', 'timezone']));

Status, Preise, Rollen, tenant_id und Freigabefelder werden nicht aus dem Request übernommen. Sie entstehen aus der bereits autorisierten Anwendungsaktion.

Ein Queue-Job erbt keinen vertrauenswürdigen Benutzerkontext

Ein Job läuft später und ohne den ursprünglichen HTTP-Request. Zwischen Dispatch und Ausführung können Benutzer gesperrt, Rollen entzogen oder Objekte verschoben worden sein. Der Job transportiert deshalb Identifikatoren, stellt den Kontext neu her und autorisiert die aktuelle Situation.

final class BuildInvoiceExport implements ShouldQueue
{
    public function __construct(
        public readonly int $tenantId,
        public readonly int $requestedBy,
        public readonly int $exportId,
    ) {}

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);
        $user = User::whereBelongsTo($tenant)->findOrFail($this->requestedBy);
        $export = Export::whereBelongsTo($tenant)->findOrFail($this->exportId);

        Gate::forUser($user)->authorize('build', $export);

        // Erst jetzt Daten laden und Export erzeugen.
    }
}

Beim Dispatch wird geprüft, ob ein Job gestartet werden darf. Bei der Ausführung wird geprüft, ob er noch handeln darf.

Dateien und externe Ziele brauchen eigene Grenzen

Uploads und ausgehende HTTP-Requests verbinden die Anwendung mit Parsern, Dateisystemen und fremden Netzen. Eine gewöhnliche Feldvalidierung deckt diese Risiken nicht vollständig ab.

Uploads Ausgehende Requests
Größe und erkannter MIME-Typ serverseitig ausgewählte Hosts
generierter Speichername DNS- und IP-Prüfung
private Ablage keine automatischen Redirects
Autorisierung bei jedem Download Connect- und Gesamt-Timeout
Isolation riskanter Parser begrenzte Response-Größe und Egress-Regeln
$document = $request->validate([
    'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
])['document'];

$path = $document->store("tenants/{$tenant->id}/documents", 'local');

$response = Http::baseUrl(config('services.billing.url'))
    ->connectTimeout(2)
    ->timeout(5)
    ->withoutRedirecting()
    ->get('/v1/status');

Laravel erkennt für mimes und mimetypes den Typ aus dem Inhalt. Das ersetzt weder Malware-Scanning noch die isolierte Verarbeitung komplexer Dokumente. Ebenso verhindert withoutRedirecting() nur die Weiterleitung; frei wählbare Ziele benötigen weiterhin Host-, DNS-, IP- und Netzwerkregeln.

Wiederholung und Parallelität gehören zum normalen Betrieb

Webhooks, Queue-Jobs und Benutzeraktionen können erneut oder gleichzeitig eintreffen. Genau-einmal-Zustellung ist keine sinnvolle Grundannahme. Das System muss Wiederholung sicher verarbeiten.

  • Ein Provider-Event besitzt einen eindeutigen Schlüssel mit Unique Constraint.
  • Die fachliche Mutation und ihre Zustandsprüfung laufen atomar.
  • Ein konkurrierendes Insert wird als bereits empfangen behandelt.
  • Externe Seiteneffekte erhalten einen eigenen Idempotenzschlüssel.
  • Ein Concurrency-Test beweist das Verhalten unter Parallelität.

Eine Transaktion allein löst weder Replay noch Idempotenz. Sie definiert nur die atomare Grenze, innerhalb der die konkrete Invariante geprüft und verändert wird.

Audit-Logs dokumentieren Entscheidungen, nicht Geheimnisse

Ein verwertbarer Eintrag beantwortet: Wer hat wann welche fachliche Aktion an welchem Objekt ausgelöst, von welchem in welchen Zustand und mit welchem Ergebnis?

{
  "event": "invoice.approved",
  "actor_id": 481,
  "tenant_id": 27,
  "subject_id": 8842,
  "from": "pending",
  "to": "approved",
  "request_id": "req_01J..."
}

Passwörter, Session-Cookies, vollständige Tokens und unnötige Dokumentinhalte gehören nicht in das Log. Nachvollziehbarkeit entsteht durch gezielte Metadaten, nicht durch eine Kopie des Requests.

Die Negativmatrix ist Teil der Definition of Done

Für jede kritische Aktion sollten mindestens diese Gegenbeweise existieren:

  • nicht angemeldeter Benutzer wird abgewiesen
  • falsche Rolle wird abgewiesen
  • richtiges Recht im falschen Mandanten wird abgewiesen
  • richtiger Benutzer im falschen Objektzustand wird abgewiesen
  • zusätzliche privilegierte Felder werden ignoriert oder abgelehnt
  • alter Link funktioniert nach Rechteentzug nicht mehr
  • wiederholte und parallele Requests verletzen keine Invariante
  • Queue, API, Export und Download erzwingen dieselbe Regel

Diese Tests sind keine zusätzliche Security-Suite neben dem Produkt. Sie dokumentieren die Grenzen der Funktion und laufen bei jedem Refactoring mit.

So beginnt ein belastbares Review

  1. Wertvolle Daten, kritische Aktionen und Rollen identifizieren.
  2. Für jede Aktion den realistischen Missbrauch und die Invariante formulieren.
  3. Alle Einstiegspunkte verfolgen: HTTP, API, Queue, Broadcast, Datei und Integration.
  4. Policy, Zustandsmodell, Datenbankgrenzen und Betrieb gemeinsam prüfen.
  5. Den Angriff reproduzieren und danach als negativen Regressionstest erhalten.

Das Ergebnis ist keine möglichst lange Findings-Liste. Es ist eine priorisierte Karte der tatsächlich erreichbaren Risiken, ihrer gemeinsamen Ursachen und der Tests, die ein Wiederauftreten verhindern.

Fazit

Laravel bringt starke Sicherheitsbausteine mit. Die entscheidende Arbeit beginnt dort, wo Framework-Funktionen enden: bei Mandanten, fachlichen Aktionen, Zuständen und asynchronen Datenwegen. Werden diese Grenzen als Invarianten formuliert und negativ getestet, entstehen gleichzeitig sicherere Software, klarere Architektur und verlässlichere Änderungen.

Weiterführende Primärquellen

Eine ähnliche Entscheidung steht in Ihrem Projekt an?

Schildern Sie den Kontext. Ich ordne die technischen Optionen, Risiken und einen sinnvollen nächsten Schritt ein.

Projektfrage besprechen ↗