Skip to content

How-to: Pagination Boundary & Limit Injection

FT177 — limitlog

Bullet-proof integer parameter validation for offset and cursor-based pagination — preventing DB dumps, overflows, type confusion, and ReDoS.


The attack surface

Every pagination endpoint exposes at least two integer parameters (limit, page / after). Attackers routinely probe these with:

AttackExampleRisk
Oversized limitlimit=999999Full-table dump
Zero/negativelimit=0, limit=-1Negative OFFSET → DB error or wrap
Float injectionlimit=10.5, limit=1e2Silent cast: (int)"10.5" === 10
Padded / signedlimit=+10, limit= 10Silent trim: (int)" 10" === 10
Integer overflowlimit=9999999999999999999964-bit wrap to negative
Non-numericlimit=abc, limit=1;DROP TABLEType error or injection
Hex / octallimit=0x10, limit=0100x → fails ctype; 010 passes!
Duplicate param?limit=5&limit=1000Last-value shadows validated one
ReDoS payloadlimit=111...1xExponential regex backtracking

The clampInt() pattern

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 → use default (not null = invalid)
    }

    $raw = $params[$key];

    // ctype_digit: O(n), ReDoS-immune, rejects '' / '-' / '.' / '+' / ' ' / 'e'
    // ctype_digit('') === false  →  empty string already rejected
    if (!is_string($raw) || !ctype_digit($raw)) {
        return null;  // signal: caller must return 422
    }

    // Prevent PHP silent overflow: (int)"99999999999999999999" wraps
    if (strlen($raw) > 18) {
        return null;
    }

    $value = (int) $raw;

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

    return $value;
}

Why ctype_digit, not regex

ValidatorReDoS safe?Rejects 010?Rejects +10?
/^\d+$/❌ exponential on 111...1x
ctype_digit()✅ O(n)✅ (0 prefix: passes — but capped by range)
is_numeric()
filter_var(FILTER_VALIDATE_INT)❌ (+10 passes!)

Use ctype_digit() — it is the strictest and fastest.

The 010 gotcha

ctype_digit('010')true (passes digit check), (int)'010'10 (decimal, not octal). This is safe because PHP does not perform octal interpretation on string-casted integers (unlike 010 as a PHP literal). Confirm in tests if your team is unsure.


Cursor-based pagination

php
// Fetch one extra row to determine has_more — no COUNT query needed
$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);  // drop the sentinel
}

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

Cursor sentinel for "first page"

php
private const int NO_CURSOR = PHP_INT_MAX;

// GET /articles/cursor (no ?after param) → afterId defaults to PHP_INT_MAX
// WHERE id < PHP_INT_MAX  ==>  effectively all rows

Offset pagination — page zero guard

page=0 produces OFFSET = (0-1) * limit = -limit — negative OFFSET is an SQL error in some databases (MySQL rejects it) or silently wraps in others.

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

Integer overflow guard

PHP's (int) cast on a 20-digit string silently wraps:

php
(int)'99999999999999999999'  // === -1 on 64-bit PHP

The strlen($raw) > 18 guard prevents this before the cast. 18 digits safely covers PHP_INT_MAX (19 digits) with a margin so the cast is always safe.


VULN-A through VULN-L checklist

#TestExpectation
VULN-Alimit over MAX (100)422 — explicit rejection, not silent truncation
VULN-Blimit=0, limit=-1422 — 0 fails min=1; - fails ctype_digit
VULN-CFloat string 10.5, 1e2, 1.0422 — . and e fail ctype_digit
VULN-DPadded %2010, 10%20, %2B10422 — space/+ fail ctype_digit
VULN-EOverflow 9999... (20 digits)422 — strlen > 18 guard
VULN-FNon-numeric, hex 0x10, SQL injection422 — ctype_digit rejects all
VULN-Gpage=0 (offset pagination)422 — min=1 guard
VULN-HCursor boundary: after=0 valid, overflow cursor 422Mixed
VULN-Iauthor_id=0, -1, abc, 1.5422
VULN-JVery large page (page=999999)200 empty — must not crash
VULN-KDuplicate param ?limit=5&limit=1000200 (safe) or 422 — never > MAX
VULN-LReDoS payload 111...1x (50 digits + x)422 in < 100ms

Testing note: VULN-J vs VULN-A

These look contradictory but serve different goals:

  • VULN-A: limit=999999422 — reject unreasonably large row count
  • VULN-J: page=999999&limit=10200 empty — a valid page that happens to have no data

The server must not crash or error on a semantically valid but practically empty page. OFFSET = (999999-1) * 10 = 9999980 is a legal SQL OFFSET; the result is simply empty.

Released under the MIT License.