Skip to content

How-to : Attaque sur les bornes de pagination et injection de limite

FT177 — limitlog

Validation de paramètres entiers infaillible pour la pagination par offset et par curseur — prévenant les dumps de DB, les dépassements, la confusion de types et les ReDoS.


La surface d'attaque

Tout endpoint de pagination expose au moins deux paramètres entiers (limit, page / after). Les attaquants sondent routinièrement ces paramètres avec :

AttaqueExempleRisque
Limite surdimensionnéelimit=999999Dump de table complète
Zéro/négatiflimit=0, limit=-1OFFSET négatif → erreur DB ou enroulement
Injection floatlimit=10.5, limit=1e2Cast silencieux : (int)"10.5" === 10
Padded / signélimit=+10, limit= 10Trim silencieux : (int)" 10" === 10
Dépassement entierlimit=99999999999999999999Enroulement 64 bits vers négatif
Non numériquelimit=abc, limit=1;DROP TABLEErreur de type ou injection
Hex / octallimit=0x10, limit=0100x → échoue ctype ; 010 passe !
Paramètre dupliqué?limit=5&limit=1000La dernière valeur masque celle validée
Payload ReDoSlimit=111...1xRetour arrière exponentiel de regex

Le pattern clampInt()

php
/**
 * @param array<string, mixed> $params
 */
private function clampInt(array $params, string $key, ?int $default, int $min, int $max): ?int
{
    if (!array_key_exists($key, $params)) {
        return $default;  // absent → utiliser le défaut (pas null = invalide)
    }

    $raw = $params[$key];

    // ctype_digit : O(n), immunisé contre ReDoS, rejette '' / '-' / '.' / '+' / ' ' / 'e'
    // ctype_digit('') === false  →  chaîne vide déjà rejetée
    if (!is_string($raw) || !ctype_digit($raw)) {
        return null;  // signal : l'appelant doit retourner 422
    }

    // Prévenir le dépassement silencieux PHP : (int)"99999999999999999999" s'enroule
    if (strlen($raw) > 18) {
        return null;
    }

    $value = (int) $raw;

    if ($value < $min || $value > $max) {
        return null;
    }

    return $value;
}

Pourquoi ctype_digit, pas de regex

ValidateurRésistant ReDoS ?Rejette 010 ?Rejette +10 ?
/^\d+$/❌ exponentiel sur 111...1x
ctype_digit()✅ O(n)✅ (préfixe 0 : passe — mais capé par plage)
is_numeric()
filter_var(FILTER_VALIDATE_INT)❌ (+10 passe !)

Utiliser ctype_digit() — c'est le plus strict et le plus rapide.

Le piège de 010

ctype_digit('010')true (passe la vérification de chiffres), (int)'010'10 (décimal, pas octal). C'est sûr car PHP n'effectue pas d'interprétation octale sur les entiers castés depuis des chaînes (contrairement à 010 comme littéral PHP). Confirmer dans les tests si votre équipe n'est pas sûre.


Pagination par curseur

php
// Récupérer une ligne supplémentaire pour déterminer has_more — pas de requête COUNT nécessaire
$rows = $this->db->fetchAll(
    'SELECT * FROM articles WHERE id < ? ORDER BY id DESC LIMIT ?',
    [$afterId, $limit + 1],
);

$hasMore = count($rows) > $limit;
if ($hasMore) {
    array_pop($rows);  // supprimer la ligne sentinelle
}

$nextCursor = $hasMore && count($rows) > 0 ? end($rows)->id : null;

Sentinelle de curseur pour la "première page"

php
private const int NO_CURSOR = PHP_INT_MAX;

// GET /articles/cursor (pas de paramètre ?after) → afterId vaut par défaut PHP_INT_MAX
// WHERE id < PHP_INT_MAX  ==>  effectivement toutes les lignes

Pagination par offset — garde page zéro

page=0 produit OFFSET = (0-1) * limit = -limit — un OFFSET négatif est une erreur SQL dans certaines bases de données (MySQL le rejette) ou s'enroule silencieusement dans d'autres.

php
$page  = $this->clampInt($params, 'page', 1, 1, PHP_INT_MAX);
// min=1 → page=0 retourne null → 422

Garde contre le dépassement d'entier

Le cast (int) de PHP sur une chaîne de 20 chiffres s'enroule silencieusement :

php
(int)'99999999999999999999'  // === -1 sur PHP 64 bits

La garde strlen($raw) > 18 prévient cela avant le cast. 18 chiffres couvre en toute sécurité PHP_INT_MAX (19 chiffres) avec une marge pour que le cast soit toujours sûr.


Liste de vérification VULN-A à VULN-L

#TestAttente
VULN-Alimit au-dessus de MAX (100)422 — rejet explicite, pas troncature silencieuse
VULN-Blimit=0, limit=-1422 — 0 échoue min=1 ; - échoue ctype_digit
VULN-CChaîne float 10.5, 1e2, 1.0422 — . et e échouent ctype_digit
VULN-DPadded %2010, 10%20, %2B10422 — espace/+ échouent ctype_digit
VULN-EDépassement 9999... (20 chiffres)422 — garde strlen > 18
VULN-FNon numérique, hex 0x10, injection SQL422 — ctype_digit rejette tout
VULN-Gpage=0 (pagination par offset)422 — garde min=1
VULN-HBorne curseur : after=0 valide, curseur débordant 422Mixte
VULN-Iauthor_id=0, -1, abc, 1.5422
VULN-JTrès grande page (page=999999)200 vide — ne doit pas planter
VULN-KParamètre dupliqué ?limit=5&limit=1000200 (sûr) ou 422 — jamais > MAX
VULN-LPayload ReDoS 111...1x (50 chiffres + x)422 en < 100ms

Note de test : VULN-J vs VULN-A

Ces cas semblent contradictoires mais servent des objectifs différents :

  • VULN-A : limit=999999422 — rejeter un nombre de lignes déraisonnablement grand
  • VULN-J : page=999999&limit=10200 vide — une page valide qui n'a simplement pas de données

Le serveur ne doit pas planter ou générer d'erreur sur une page sémantiquement valide mais pratiquement vide. OFFSET = (999999-1) * 10 = 9999980 est un OFFSET SQL légal ; le résultat est simplement vide.

Publié sous licence MIT.