Skip to content

How-to : Tri dynamique et filtrage avec prévention d'injection ORDER BY

Référence FT : FT341 (NENE2-FT/sortlog) — API de tri/filtrage dynamique avec prévention d'injection SQL ORDER BY via liste blanche, liste blanche de filtre de statut, validation O(n) immune aux ReDoS, 40+ tests couvrant VULN-A à VULN-L et ATK-01 à ATK-12, tous PASS.

Ce guide montre comment implémenter un endpoint de liste triable et filtrable de façon sûre. Comme ORDER BY ne peut pas utiliser de placeholders paramétrisés en SQL, la colonne et la direction doivent être validées contre une liste blanche stricte avant interpolation.

Schéma

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
);

Endpoint

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

Paramètres valides

ParamValeurs autoriséesDéfaut
sortid, title, status, created_atcreated_at
orderasc, descdesc
statusdraft, published, archived(tous)
limit1–10020

Réponse

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"
}

Validation par liste blanche — Le seul pattern sûr

Les clauses ORDER BY ne peuvent pas utiliser des valeurs de binding paramétrisées. Le nom de colonne doit être interpolé directement dans le SQL. Cela rend la validation par liste blanche obligatoire.

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
{
    // Correspondance de chaîne exacte dans la liste blanche — O(n), sensible à la casse, pas de 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;  // pas de filtre
    }
    if (!in_array($status, self::STATUSES, true)) {
        throw new ValidationException("Invalid status: {$status}");
    }
    return $status;
}

Pourquoi in_array() plutôt que regex :

  • in_array($v, $list, true) est O(n) — immune aux ReDoS
  • La regex /^[a-z_]+$/ sur des payloads attaquants de 50 caractères peut causer un backtracking catastrophique
  • Le troisième argument strict (true) active la comparaison type-safe

Sensibilité à la casse

La liste blanche est sensible à la casse par conception :

php
GET /articles?sort=ID 422  // 'ID' pas dans la liste blanche
GET /articles?sort=TITLE 422
GET /articles?sort=Created_At 422
GET /articles?sort=created_at 200 correspondance exacte

Construction de requête

php
$sql = 'SELECT * FROM articles';

if ($status !== null) {
    // Le statut utilise un placeholder paramétrisé (sûr)
    $sql .= ' WHERE status = ?';
    $params[] = $status;
}

// La colonne et la direction de tri viennent de la liste blanche — sûr à interpoler
$sql .= " ORDER BY {$sort} {$order}";
$sql .= ' LIMIT ?';
$params[] = $limit;

ORDER BY utilise des valeurs interpolées de liste blanche ; les valeurs de la clause WHERE utilisent toujours des placeholders ?.

Payloads rejetés

Patterns d'injection → 422

php
// Injection SQL dans 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  // commentaire
?sort=created_at%00                       → 422  // octet nul
?sort=1                                   → 422  // index de colonne (pas dans la liste blanche)

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

// Injection de filtre de statut
?status=' OR '1'='1                       → 422
?status=draft UNION SELECT 1,2--          → 422
?status=1                                 → 422  // doit être un nom de statut exact
?status=TRUE                              → 422

// Contournement par espaces blancs
?sort=created_at%09                       → 422  // TAB
?sort= created_at                         → 422  // espace en tête

// Injection de tableau (PSR-7)
?sort[]=created_at                        → 422  // tableau, pas chaîne

Injection de limite → 422

php
?limit=999999 422  // dépasse MAX_LIMIT=100
?limit=9999999999999999999999 422  // débordement (strlen > 18)
?limit=-1 422  // négatif
?limit=10.5 422  // float

Requêtes valides → 200

php
GET /articles 200  // défauts
GET /articles?sort=title&order=asc 200
GET /articles?sort=id&order=desc&status=draft 200
GET /articles?limit=50 200

Sécurité de timing

Chaque rejet est instantané (<100ms). La vérification de liste blanche utilise in_array() qui court-circuite à la première non-correspondance — pas de backtracking regex :

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

Ce qu'il ne faut PAS faire

Anti-patternRisque
Interpoler ?sort= directement : ORDER BY $sortInjection SQL — l'attaquant contrôle entièrement la clause ORDER BY
Valider avec regex /^[a-z_]+$/ uniquementReDoS sur des payloads de 50+ caractères ; autorise les noms de colonnes inconnus comme password
Comparaison insensible à la casse (strcasecmp)ORDER BY CREATED_AT est du SQL valide mais contourne les tests sensibles à la casse
Paramétiser ORDER BY $sort comme valeur de bindingLa plupart des drivers DB le traitent silencieusement comme un littéral ou lancent une erreur
Liste blanche uniquement sort, pas la direction orderorder=asc; UNION SELECT ... contourne la vérification de colonne
Faire confiance au tableau sort[] après le parsing PSR-7implode(', ', $sort) avec injection de tableau produit un ORDER BY multi-colonnes
Omettre la liste blanche du filtre statusstatus=admin' OR '1'='1 devient WHERE status = 'admin' OR '1'='1'

Publié sous licence MIT.