P
imvdmolen.nl
Blog

Hoe ik SQL-injectie aanvallen voorkom met behulp van prepared statements in PHP

Laatst kreeg ik een paniektelefoon van een klant. Hun website was gehackt en alle klantgegevens stonden op een obscure forum. Na wat onderzoek bleek dat een simpele SQL-injectie de oorzaak was. De developer had gebruikersinput direct in een query gestopt zonder enige bescherming. Dit soort situaties zie ik helaas nog steeds regelmatig voorbijkomen, ook bij ervaren teams. Het frustrerende is dat SQL-injectie relatief makkelijk te voorkomen is met prepared statements, maar toch gaan er nog steeds dingen mis.

Zelf heb ik ook mijn lessen geleerd. Jaren geleden bouwde ik een intranet voor een klein bedrijf. Alles leek prima te werken tot een nieuwsgierige medewerker ontdekte dat hij met een simpele ' OR '1'='1 in het wachtwoordveld toegang kreeg tot alle accounts. Gelukkig gebeurde dit intern en niet door een echte aanvaller, maar het was wel een harde wake-up call. Sindsdien gebruik ik altijd prepared statements voor elke database-interactie.

Waarom SQL-injectie zo gevaarlijk is

SQL-injectie ontstaat wanneer gebruikersinput direct wordt samengevoegd met een SQL-query zonder proper escaping of parameterisering. Een aanvaller kan dan SQL-commando's injecteren die de oorspronkelijke query wijzigen. Het klassieke voorbeeld is een login-formulier waar je admin' -- als gebruikersnaam invult. De dubbele streepjes zorgen ervoor dat de rest van de query wordt genegeerd, inclusief de wachtwoordcontrole.

Wat veel mensen onderschatten is dat SQL-injectie niet alleen gebruikt wordt om in te loggen. Aanvallers kunnen complete databases leeghalen, data wijzigen of zelfs server-commando's uitvoeren als de database-gebruiker teveel rechten heeft. Bij een project voor een webshop zag ik eens logs waarin iemand systematisch alle tabelnamen probeerde te achterhalen via UNION SELECT queries. Gelukkig gebruikten we prepared statements, dus al die pogingen faalden.

Prepared statements werken door de query-structuur en de data gescheiden te houden. De database compileert eerst de query zonder de parameters. Daarna worden de parameters toegevoegd, maar deze kunnen de query-structuur niet meer wijzigen. Het is alsof je een formulier met invulvelden hebt: iemand kan rare dingen in de velden typen, maar de structuur van het formulier blijft hetzelfde.

Prepared statements in pure PHP

Met PDO in PHP maak je heel eenvoudig prepared statements. Ik gebruik PDO al jaren omdat het database-agnostisch is en een consistente interface biedt. Het patroon is altijd hetzelfde: prepare, bind (optioneel), execute.

$stmt = $pdo->prepare("SELECT * FROM gebruikers WHERE email = ? AND actief = ?");
$stmt->execute([$email, 1]);
$gebruiker = $stmt->fetch(PDO::FETCH_ASSOC);

Je kunt ook named parameters gebruiken, wat ik persoonlijk prettiger vind bij complexere queries:

$stmt = $pdo->prepare("SELECT * FROM bestellingen WHERE klant_id = :klant_id AND datum >= :vanaf");
$stmt->execute([
    ':klant_id' => $klantId,
    ':vanaf' => $vanafDatum
]);

Een veelgemaakte fout is het proberen om tabelnamen of kolomnamen te parametriseren. Dat werkt niet met prepared statements. Als je dynamische tabelnamen nodig hebt, moet je die apart valideren tegen een whitelist voordat je ze in de query gebruikt. Bij een rapportage-tool moest ik dit oplossen door alle toegestane tabelnamen in een array te zetten en daar tegenaan te checken.

Mysqli heeft ook prepared statements, hoewel de syntax wat omslachtiger is. Ik kom het nog wel eens tegen in legacy-projecten waar PDO er later is bijgekomen:

$stmt = $mysqli->prepare("SELECT id, naam FROM producten WHERE categorie = ?");
$stmt->bind_param("s", $categorie);
$stmt->execute();
$result = $stmt->get_result();

Laravel's aanpak met Eloquent en Query Builder

Laravel maakt prepared statements eigenlijk transparant door Eloquent en de Query Builder. Onder de motorkap gebruikt Laravel altijd parameter binding, zelfs als je het niet bewust doet. Dit is een van de redenen waarom ik Laravel zo fijn vind voor webapplicaties: security wordt automatisch goed geregeld.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Gebruiker extends Model
{
    public function scopeActief($query)
    {
        return $query->where('actief', true);
    }
}

Met Eloquent zijn alle queries automatisch safe. Gebruiker::where('email', $email)->first() wordt onder de motorkap omgezet naar een prepared statement. Zelfs bij complexere queries blijft dit werken:

$bestellingen = Bestelling::where('klant_id', $klantId)
    ->where('datum', '>=', $vanafDatum)
    ->with('klant', 'producten')
    ->paginate(20);

De Query Builder geeft je meer directe controle als je Eloquent te beperkt vindt, maar houdt wel de veiligheid van prepared statements:

$results = DB::table('bestellingen')
    ->select('klant_id', DB::raw('COUNT(*) as aantal'))
    ->where('datum', '>=', $vanafDatum)
    ->groupBy('klant_id')
    ->get();

Let op bij DB::raw(): dit schakelt parameter binding uit voor dat specifieke deel. Gebruik dit alleen met data die je volledig vertrouwt, nooit met gebruikersinput. Voor dynamische kolomnamen of complexe expressies is het soms noodzakelijk, maar wees dan extra voorzichtig.

Aanvullende beveiligingsmaatregelen

Prepared statements zijn cruciaal, maar niet het enige wat je nodig hebt voor een veilige applicatie. Input validatie blijft belangrijk, ook al voorkomt het geen SQL-injectie. Ik valideer altijd of een email-adres er daadwerkelijk uitziet als een email, of een datum een geldige datum is. Laravel's Form Request Validation is hiervoor perfect:

public function rules()
{
    return [
        'email' => 'required|email|max:255',
        'geboortedatum' => 'required|date|before:today',
        'postcode' => 'required|regex:/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/'
    ];
}

Database-rechten zijn een andere belangrijke laag. Ik geef webapplicatie-gebruikers nooit meer rechten dan noodzakelijk. Meestal betekent dit alleen SELECT, INSERT, UPDATE en DELETE op specifieke tabellen. Geen CREATE, DROP of ALTER rechten. Bij een klant wilde de junior developer alle rechten "voor het gemak", maar dat is precies hoe kleine problemen grote problemen worden.

Error handling verdient ook aandacht. Toon nooit database-errors aan eindgebruikers. Die bevatten vaak gevoelige informatie zoals tabelnamen, kolomnamen of server-paths. Log ze wel voor debugging, maar toon generieke foutmeldingen aan gebruikers. Laravel doet dit standaard goed met zijn exception handling.

Regular security audits zijn onmisbaar geworden. Ik draai maandelijks automated scans met tools als OWASP ZAP op alle projecten. Handmatige penetration testing laat ik jaarlijks doen door een externe partij. Het kost tijd en geld, maar is veel goedkoper dan een datalek oplossen.

De grootste les die ik geleerd heb is dat beveiliging geen eenmalige actie is, maar een mindset. Elke keer als ik code schrijf die met een database praat, denk ik bewust na over SQL-injectie. Het is inmiddels zo'n automatisme geworden dat ik prepared statements gebruik zonder er bij stil te staan. Dat is precies hoe het hoort: veiligheid moet vanzelfsprekend worden, niet iets waar je achteraf aan denkt.