Skip to content

How-to: Dynamisches Sortieren & Filtern mit ORDER-BY-Injection-Prävention ​

FT-Referenz: FT341 (NENE2-FT/sortlog) — Dynamische Sort/Filter-API mit SQL-ORDER-BY-Injection-Prävention via Allowlist, Status-Filter-Allowlist, ReDoS-immune O(n)-Validierung, 40+ Tests für VULN-A bis VULN-L und ATK-01 bis ATK-12, alle bestanden.

Diese Anleitung zeigt, wie ein sortierbarer, filterbarer List-Endpunkt sicher implementiert wird. Da ORDER BY in SQL keine parametrisierten Platzhalter verwenden kann, müssen Spalte und Richtung gegen eine strikte Allowlist validiert werden, bevor sie interpoliert werden.

Schema ​

sql
CREATE TABLE articles (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL,
    status     TEXT    NOT NULL DEFAULT 'draft',
    created_at TEXT    NOT NULL
);

Endpunkt ​

GET /articles?sort=created_at&order=desc&status=published&limit=20

Gültige Parameter ​

ParamErlaubte WerteStandard
sortid, title, status, created_atcreated_at
orderasc, descdesc
statusdraft, published, archived(alle)
limit1–10020

Antwort ​

php
GET /articles?sort=title&order=asc&status=published
→ 200
{
  "data": [
    {"id": 2, "title": "Alpha", "status": "published", ...},
    {"id": 1, "title": "Beta",  "status": "published", ...}
  ],
  "total": 2,
  "sort": "title",
  "order": "asc"
}

Allowlist-Validierung — Das einzig sichere Muster ​

ORDER BY-Klauseln können keine parametrisierten Bind-Werte verwenden. Der Spaltenname muss direkt in SQL interpoliert werden. Das macht Allowlist-Validierung zwingend erforderlich.

php
private const SORT_COLUMNS = ['id', 'title', 'status', 'created_at'];
private const SORT_ORDERS  = ['asc', 'desc'];
private const STATUSES     = ['draft', 'published', 'archived'];

public function parseSort(string $sort, string $order): array
{
    // Exakter String-Abgleich in der Allowlist — O(n), case-sensitiv, kein Regex
    if (!in_array($sort, self::SORT_COLUMNS, true)) {
        throw new ValidationException("Invalid sort column: {$sort}");
    }
    if (!in_array($order, self::SORT_ORDERS, true)) {
        throw new ValidationException("Invalid sort direction: {$order}");
    }
    return [$sort, $order];
}

public function parseStatus(?string $status): ?string
{
    if ($status === null) {
        return null;  // kein Filter
    }
    if (!in_array($status, self::STATUSES, true)) {
        throw new ValidationException("Invalid status: {$status}");
    }
    return $status;
}

Warum in_array() statt Regex:

  • in_array($v, $list, true) ist O(n) — immun gegen ReDoS
  • Regex /^[a-z_]+$/ auf angreifer-kontrollierten 50-Zeichen-Payloads kann katastrophales Backtracking verursachen
  • Striktes drittes Argument (true) aktiviert typensicheren Vergleich

Groß-/Kleinschreibungsempfindlichkeit ​

Die Allowlist ist designbedingt case-sensitiv:

php
GET /articles?sort=ID       → 422  // 'ID' nicht in der Allowlist
GET /articles?sort=TITLE    → 422
GET /articles?sort=Created_At → 422
GET /articles?sort=created_at → 200  ✅ exakter Treffer

Abfragekonstruktion ​

php
$sql = 'SELECT * FROM articles';

if ($status !== null) {
    // Status verwendet einen parametrisierten Platzhalter (sicher)
    $sql .= ' WHERE status = ?';
    $params[] = $status;
}

// Sort-Spalte und Richtung kommen aus der Allowlist — sicher zu interpolieren
$sql .= " ORDER BY {$sort} {$order}";
$sql .= ' LIMIT ?';
$params[] = $limit;

ORDER BY verwendet interpolierte Allowlist-Werte; WHERE-Klausel-Werte verwenden immer ?-Platzhalter.

Abgelehnte Payloads ​

Injection-Muster → 422 ​

php
// SQL-Injection in sort
?sort='; DROP TABLE articles--             → 422
?sort=id UNION SELECT 1,2,3,4,5           → 422
?sort=(SELECT name FROM sqlite_master)    → 422
?sort=CASE WHEN 1=1 THEN id ELSE title END → 422
?sort=created_at--                        → 422  // Kommentar
?sort=created_at%00                       → 422  // Null-Byte
?sort=1                                   → 422  // Spaltenindex (nicht in Allowlist)

// Richtungs-Injection
?order=asc; UNION SELECT 1,2,3--          → 422
?order=DESC;                              → 422

// Status-Filter-Injection
?status=' OR '1'='1                       → 422
?status=draft UNION SELECT 1,2--          → 422
?status=1                                 → 422  // muss exakter Statusname sein
?status=TRUE                              → 422

// Whitespace-Umgehung
?sort=created_at%09                       → 422  // TAB
?sort= created_at                         → 422  // führendes Leerzeichen

// Array-Injection (PSR-7)
?sort[]=created_at                        → 422  // Array, kein String

Limit-Injection → 422 ​

php
?limit=999999           → 422  // überschreitet MAX_LIMIT=100
?limit=9999999999999999999999  → 422  // Überlauf (strlen > 18)
?limit=-1               → 422  // negativ
?limit=10.5             → 422  // Float

Gültige Anfragen → 200 ​

php
GET /articles                                  → 200  // Standardwerte
GET /articles?sort=title&order=asc             → 200
GET /articles?sort=id&order=desc&status=draft  → 200
GET /articles?limit=50                         → 200

Timing-Sicherheit ​

Jede Ablehnung ist augenblicklich (<100ms). Die Allowlist-Prüfung verwendet in_array(), das beim ersten Nicht-Treffer abbricht — kein Regex-Backtracking:

php
// ReDoS-Payload: "aaaa...a!" (50 a's + '!')
// in_array("aaaa...a!", ['id','title','status','created_at'], true) → sofort false

Was man NICHT tun sollte ​

Anti-MusterRisiko
?sort= direkt interpolieren: ORDER BY $sortSQL-Injection — Angreifer kontrolliert die ORDER BY-Klausel vollständig
Nur mit Regex /^[a-z_]+$/ validierenReDoS auf 50+-Zeichen-Payloads; erlaubt unbekannte Spaltennamen wie password
Groß-/Kleinschreibungsinsensitiver Vergleich (strcasecmp)ORDER BY CREATED_AT ist gültiges SQL, umgeht aber case-sensitive Tests
ORDER BY $sort als Bind-Wert parametrisierenDie meisten DB-Treiber behandeln es stillschweigend als Literal oder werfen einen Fehler
Nur sort, nicht order-Richtung in Allowlistorder=asc; UNION SELECT ... umgeht die Spaltenprüfung
sort[]-Array nach PSR-7-Parsing vertrauenimplode(', ', $sort) mit Array-Injection produziert Multi-Spalten-ORDER-BY
status-Filter-Allowlist weglassenstatus=admin' OR '1'='1 wird zu WHERE status = 'admin' OR '1'='1'

Veröffentlicht unter der MIT-Lizenz.