jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / Invoervalidatie &Uitvoercodering

Invoervalidatie &Uitvoercodering

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 value

Een 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.normalized

Unicode-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 value

Regel: 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 <&lt;
HTML attribuut HTML entity encoding + aanhalingstekens "&quot;
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)
# &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
// Java — OWASP Java Encoder
import org.owasp.encoder.Encode;

String safe = Encode.forHtml(userInput);
String safeAttr = Encode.forHtmlAttribute(userInput);
String safeJs = Encode.forJavaScript(userInput);
// JavaScript (server-side Node.js)
const he = require('he');

const safe = he.encode(userInput);
// PHP
$safe = htmlspecialchars($userInput, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// 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ëscaped

URL-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 = "&lt;script&gt;"
# Eerste keer: al geëncoded
html.escape(user_input)
# Resultaat: "&amp;lt;script&amp;gt;" — dubbel encoded, zichtbaar als &lt;script&gt;

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(), utext en 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_name

CLI-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?

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home