Invoervalidatie & Uitvoercodering
Code Met Grenzen, Productie Met Rust
Webrisico is zelden mysterieus. Het zit meestal in voorspelbare fouten die onder tijdsdruk blijven staan.
Bij Invoervalidatie & Uitvoercodering zit de meeste winst in veilige defaults die in elke release automatisch worden afgedwongen.
Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.
Directe maatregelen (15 minuten)
Waarom dit telt
De kern van Invoervalidatie & Uitvoercodering is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.
Kernprincipes
Valideer invoer, codeer uitvoer
┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────────┐
│ Invoer │────▶│ Validatie │────▶│ Business │────▶│ Uitvoer- │────▶ Output
│ (onvertr)│ │ (allowlist)│ │ Logic │ │ codering │
└──────────┘ └────────────┘ └──────────┘ └──────────────┘
- Invoervalidatie is generiek: “Is dit een geldig e-mailadres? Een getal tussen 1 en 100?”
- Uitvoercodering is context-specifiek: “Ga ik deze waarde in HTML, JavaScript, SQL of een URL plaatsen?”
Nooit vertrouwen
Alle invoer is onvertrouwd. Niet alleen formuliervelden, maar ook:
- HTTP-headers (Host, Referer, User-Agent, X-Forwarded-For)
- Cookies
- URL-parameters en pad-segmenten
- Bestandsnamen bij uploads
- API-responses van externe services
- Database-inhoud (kan eerder geïnjecteerd zijn)
Invoervalidatie
Allowlist boven blocklist
# FOUT — blocklist: probeer bekende slechte patronen te blokkeren
def sanitize_input(value):
blacklist = ['<script>', 'DROP TABLE', '../', ';']
for bad in blacklist:
value = value.replace(bad, '')
return value # Eindeloos te bypassen
# GOED — allowlist: definieer wat WEL mag
import re
def validate_username(value):
if not re.fullmatch(r'[a-zA-Z0-9_]{3,30}', value):
raise ValueError("Ongeldige gebruikersnaam")
return valueEen blocklist is een race die je altijd verliest. Er zijn oneindig veel manieren om kwaadaardige invoer te encoderen. Een allowlist definieert de eindige set van geldige waarden.
Type, range en format
# Type-validatie
def validate_age(value):
age = int(value) # TypeError als het geen getal is
if not 0 <= age <= 150: # Range-check
raise ValueError("Leeftijd buiten bereik")
return age
# Format-validatie met regex
import re
def validate_dutch_postcode(value):
if not re.fullmatch(r'\d{4}\s?[A-Z]{2}', value):
raise ValueError("Ongeldige postcode")
return value.replace(' ', '') # Normaliseer naar '1234AB'
# E-mail: gebruik een library, schrijf geen eigen regex
from email_validator import validate_email
def validate_email_address(value):
result = validate_email(value)
return result.normalizedUnicode-normalisatie
Unicode biedt meerdere representaties voor hetzelfde teken. Zonder normalisatie kunnen identiek lijkende strings anders zijn:
import unicodedata
# 'café' kan op twee manieren gecodeerd zijn:
nfc = unicodedata.normalize('NFC', user_input) # Samengesteld: é
nfkc = unicodedata.normalize('NFKC', user_input) # Compatibel: fi → fi
# Normaliseer VOOR validatie
def validate_name(value):
value = unicodedata.normalize('NFC', value)
if not re.fullmatch(r'[\w\s\-]{1,100}', value, re.UNICODE):
raise ValueError("Ongeldige naam")
return valueRegel: Normaliseer Unicode voor je valideert, en valideer voor je opslaat. Hiermee voorkom je bypass via homogliefen (Cyrilisch а vs Latijns a) en width-varianten.
Lengtebeperking
Beperk altijd de lengte van invoer. Dit voorkomt:
- Buffer overflows
- ReDoS (Regular Expression Denial of Service)
- Database-overloop
- Resource exhaustion
MAX_COMMENT_LENGTH = 5000
def validate_comment(value):
if len(value) > MAX_COMMENT_LENGTH:
raise ValueError(f"Commentaar te lang (max {MAX_COMMENT_LENGTH} tekens)")
return value.strip()Uitvoercodering per context
De juiste codering hangt af van waar je de data plaatst. Dit is de meest kritieke les: er bestaat geen universele sanitize-functie.
Context-matrix
| Uitvoercontext | Coderingsmethode | Voorbeeld |
|---|---|---|
| HTML body | HTML entity encoding | < → < |
| HTML attribuut | HTML entity encoding + aanhalingstekens | " → " |
| JavaScript string | JavaScript string escaping | ' → \', \n →
\\n |
| URL parameter | Percent-encoding | → %20, & →
%26 |
| CSS waarde | CSS escaping | \ → \\, ( →
\28 |
| SQL query | Parameterized queries | Geen codering — gebruik placeholders |
| JSON | JSON serialisatie | Gebruik json.dumps(), nooit string concatenatie |
| Command line | Geen codering — gebruik arrays | Geen shell, pass args als lijst |
HTML entity encoding
# Python — standaardbibliotheek
import html
user_input = '<script>alert("XSS")</script>'
safe = html.escape(user_input)
# <script>alert("XSS")</script>// Java — OWASP Java Encoder
import org.owasp.encoder.Encode;
String safe = Encode.forHtml(userInput);
String safeAttr = Encode.forHtmlAttribute(userInput);
String safeJs = Encode.forJavaScript(userInput);// C# — System.Text.Encodings.Web
using System.Text.Encodings.Web;
string safe = HtmlEncoder.Default.Encode(userInput);
string safeJs = JavaScriptEncoder.Default.Encode(userInput);
string safeUrl = UrlEncoder.Default.Encode(userInput);JavaScript string escaping
# Nooit dit:
f"var name = '{user_input}';" # XSS via '; alert(1); //
# Wel dit:
import json
f"var name = {json.dumps(user_input)};" # Veilig geëscapedURL-encoding
from urllib.parse import quote, urlencode
# Enkele parameter
safe_param = quote(user_input)
# Meerdere parameters
params = urlencode({'search': user_input, 'page': '1'})
url = f"https://example.com/search?{params}"SQL — altijd parameterized queries
# FOUT — string concatenatie
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")
# GOED — parameterized
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))// GOED — PreparedStatement
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?");
stmt.setString(1, name);// GOED — SqlParameter
using var cmd = new SqlCommand(
"SELECT * FROM users WHERE name = @name", conn);
cmd.Parameters.AddWithValue("@name", name);JSON serialisatie
import json
# FOUT — handmatige constructie
response = '{"name": "' + user_input + '"}'
# GOED — json.dumps escaped automatisch
response = json.dumps({"name": user_input})Command line — nooit shell=True
import subprocess
# FOUT — command injection via shell
subprocess.run(f"convert {filename} output.png", shell=True)
# GOED — argumenten als lijst, geen shell
subprocess.run(["convert", filename, "output.png"])Libraries per taal
| Taal | Library | Functionaliteit |
|---|---|---|
| JavaScript | DOMPurify | HTML sanitisatie (client-side) |
| JavaScript | he | HTML entity encode/decode |
| Python | bleach | HTML sanitisatie (server-side) |
| Python | html.escape | Basis HTML escaping |
| Python | markupsafe | Jinja2 auto-escaping |
| Java | OWASP Java Encoder | Context-specifieke encoding |
| Java | jsoup | HTML sanitisatie + parsing |
| Go | html/template | Auto-escaping templates |
| Go | bluemonday | HTML sanitisatie |
| C# | HtmlSanitizer | HTML sanitisatie |
| C# | System.Text.Encodings.Web | HTML/JS/URL encoding |
| PHP | htmlspecialchars | HTML escaping (ingebouwd) |
| PHP | HTMLPurifier | HTML sanitisatie |
DOMPurify (JavaScript, client-side)
// HTML sanitisatie met DOMPurify
const clean = DOMPurify.sanitize(userInput);
// Met configuratie — alleen bepaalde tags toestaan
const clean = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});bleach (Python, server-side)
import bleach
# Basis sanitisatie
clean = bleach.clean(user_input)
# Met allowlist
clean = bleach.clean(
user_input,
tags=['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
attributes={'a': ['href', 'title']},
protocols=['https'],
)Valkuilen
Dubbele encoding
# user_input = "<script>"
# Eerste keer: al geëncoded
html.escape(user_input)
# Resultaat: "&lt;script&gt;" — dubbel encoded, zichtbaar als <script>Oplossing: encodeer op één plek, zo laat mogelijk (bij de uitvoer).
Template engines en auto-escaping
De meeste moderne template engines escapen automatisch:
| Template engine | Auto-escape standaard? | Bypass syntax |
|---|---|---|
| Jinja2 (Flask) | Ja | {{ value\|safe }} of
{% autoescape false %} |
| Django templates | Ja | {{ value\|safe }} of
{% autoescape off %} |
| Go html/template | Ja | template.HTML(value) |
| Thymeleaf (Java) | Ja | th:utext (unescaped) |
| Razor (C#) | Ja | @Html.Raw(value) |
| ERB (Ruby) | Nee (standaard) | <%= value %> escaped met h() |
| PHP | Nee | Handmatig htmlspecialchars() |
Regel: Gebruik
|safe,Raw(),utexten vergelijkbare bypass- mechanismen alleen op waarden die je zelf hebt gegenereerd of al hebt gesanitiseerd. Nooit op gebruikersinvoer.
Mixed contexts
<!-- GEVAARLIJK — JavaScript in een HTML attribuut -->
<a href="#" onclick="doSomething('{{ user_input }}')">Click</a>Hier ben je in twee contexten tegelijk: HTML attribuut en JavaScript. Je moet eerst JavaScript-escapen, dan HTML-attribuut-escapen. Dit is foutgevoelig en moet worden vermeden. Gebruik in plaats daarvan:
<a href="#" id="action-link" data-value="{{ user_input }}">Click</a>
<script>
document.getElementById('action-link').addEventListener('click', function() {
doSomething(this.dataset.value);
});
</script>Validatie aan systeemgrenzen
API-endpoints
from pydantic import BaseModel, Field, validator
class CreateUserRequest(BaseModel):
username: str = Field(min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_]+$')
email: str = Field(max_length=254)
age: int = Field(ge=18, le=150)
@validator('email')
def validate_email(cls, v):
# Gebruik een library voor e-mailvalidatie
if '@' not in v or '.' not in v.split('@')[1]:
raise ValueError('Ongeldig e-mailadres')
return v.lower()
@app.post('/api/users')
def create_user(data: CreateUserRequest):
# data is gevalideerd door Pydantic
...Database-laag
# Beperk querylengtes
MAX_SEARCH_LENGTH = 200
def search_products(query: str):
query = query[:MAX_SEARCH_LENGTH].strip()
return db.execute(
"SELECT * FROM products WHERE name LIKE %s LIMIT 50",
(f"%{query}%",)
)Bestandssysteem
import os
UPLOAD_DIR = '/var/www/uploads'
def safe_save(filename: str, content: bytes):
# Verwijder pad-componenten
filename = os.path.basename(filename)
# Allowlist bestandsextensies
allowed_ext = {'.pdf', '.png', '.jpg', '.docx'}
_, ext = os.path.splitext(filename)
if ext.lower() not in allowed_ext:
raise ValueError(f"Bestandstype {ext} niet toegestaan")
# Genereer een veilige bestandsnaam
import uuid
safe_name = f"{uuid.uuid4().hex}{ext.lower()}"
# Controleer dat het pad binnen UPLOAD_DIR blijft
full_path = os.path.join(UPLOAD_DIR, safe_name)
if not os.path.realpath(full_path).startswith(os.path.realpath(UPLOAD_DIR)):
raise ValueError("Pad-traversal gedetecteerd")
with open(full_path, 'wb') as f:
f.write(content)
return safe_nameCLI-parameters
import subprocess
import shlex
# FOUT — shell injection
def run_tool(target):
subprocess.run(f"nmap {target}", shell=True)
# GOED — argumenten als lijst
def run_tool(target):
# Valideer eerst
import re
if not re.fullmatch(r'[\w.\-:]+', target):
raise ValueError("Ongeldig doel")
subprocess.run(["nmap", target])Checklist
| Maatregel | Beschrijving | Prioriteit |
|---|---|---|
| Allowlist-validatie | Definieer wat mag, blokkeer de rest | Kritiek |
| Type- en range-checks | Getal is getal, datum is datum | Kritiek |
| Lengtebeperking | Maximale lengte op alle invoervelden | Hoog |
| Unicode-normalisatie | NFC/NFKC voor validatie | Hoog |
| Parameterized queries | Nooit string-concatenatie in SQL | Kritiek |
| Template auto-escaping | Zorg dat het aanstaat en niet onnodig bypass | Kritiek |
| Context-specifieke codering | Gebruik de juiste encoding per sink | Kritiek |
| Bestandsnaam-sanitisatie | os.path.basename() + allowlist extensies |
Hoog |
| Command arguments als lijst | subprocess.run(["cmd", arg]), nooit
shell=True |
Kritiek |
| API-schemavalidatie | Pydantic, JSON Schema, of equivalent | Hoog |
Het is eigenlijk heel eenvoudig. Je hebt twee regels nodig. Twee.
Regel één: vertrouw niets dat van buiten komt. Niet het formulierveld, niet de URL, niet de header, niet het cookie, niet het bestand, niet de API-response van de “vertrouwde partner” wiens systeem je vorig jaar nog hebt gepentest en dat toen drie kritieke SQL-injecties had.
Regel twee: als data je systeem verlaat — naar de browser, de database, het bestandssysteem, de command line — codeer het voor die specifieke context. HTML in HTML, JavaScript in JavaScript, SQL via parameters.
Twee regels. Dat is het. En toch bestaan SQL injection en XSS al meer dan vijfentwintig jaar. We hebben ze niet opgelost. We hebben ze niet eens verminderd. Ze staan nog steeds in de OWASP Top 10. Ze stonden in de eerste OWASP Top 10, in 2003. Drieëntwintig jaar geleden.
De oplossing is bekend. De tools bestaan. De libraries zijn gratis. De documentatie is uitstekend. Maar ergens tussen “we weten hoe het moet” en “we doen het ook daadwerkelijk” zit een kloof zo breed dat je er een datacenter in kwijt kunt. En in die kloof staat een PostIt-briefje met “TODO: input sanitization” dat er al staat sinds de eerste sprint.
Samenvatting
Invoervalidatie en uitvoercodering zijn de fundamentele verdedigingen tegen injectie-aanvallen. Valideer met allowlists, beperk types en lengtes, en normaliseer Unicode voor verwerking. Codeer elke uitvoer voor de specifieke context: HTML entities voor HTML, parameterized queries voor SQL, lijsten voor commandoregel-argumenten.
In het volgende hoofdstuk behandelen we de transportlaag: hoe configureer je TLS zodat al die zorgvuldig gevalideerde en gecodeerde data ook veilig over het netwerk reist?
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: