Skip to content

Distinguish preg_match() engine failure from "no match" in all dispatchers - #305

Open
dualfroz wants to merge 1 commit into
nikic:masterfrom
dualfroz:dualfroz/fix-preg-error-not-notfound
Open

Distinguish preg_match() engine failure from "no match" in all dispatchers#305
dualfroz wants to merge 1 commit into
nikic:masterfrom
dualfroz:dualfroz/fix-preg-error-not-notfound

Conversation

@dualfroz

@dualfroz dualfroz commented Sep 5, 2026

Copy link
Copy Markdown

Problem (#167)

#167

All dispatcher strategies (GroupCountBased, GroupPosBased, CharCountBased,
MarkBased) call preg_match() against a route's compiled regex and check
only !== 1 to decide whether the route matched:

if (preg_match($data['regex'], $uri, $matches) !== 1) {
    continue;
}

preg_match() returns false (not 0) when the underlying PCRE engine
fails - for example PREG_BACKTRACK_LIMIT_ERROR when a catastrophically
backtracking route pattern exceeds pcre.backtrack_limit. The !== 1 check
treats false exactly like 0 ("no match"), so the engine error is
silently swallowed and dispatch continues to the next route as if this one
simply didn't match.

Because DataGenerator\RegexBasedAbstract combines multiple dynamic routes
for the same HTTP method into shared alternation regex chunks (to keep the
number of preg_match() calls low), a single catastrophically backtracking
route can poison the whole chunk: when preg_match() fails on the combined
regex, an unrelated, cheap route in the same chunk that would otherwise
have matched the request URI is spuriously treated as not found, and the
request can incorrectly fall through to a 404 (NOT_FOUND).

Root cause

  • src/Dispatcher/GroupCountBased.php:18
  • src/Dispatcher/GroupPosBased.php:18
  • src/Dispatcher/CharCountBased.php:21
  • src/Dispatcher/MarkBased.php:17

Each of these lines called preg_match(...) !== 1 directly, conflating
"no match" (0) with "engine failure" (false).

Fix

Added a shared, error-checked helper to the common base class
src/Dispatcher/RegexBasedAbstract.php:

protected function matchRoute(string $regex, string $subject, ?array &$matches = null): int
{
    $result = preg_match($regex, $subject, $matches);
    if ($result === false) {
        throw new RuntimeException(
            sprintf('Regex matching failed for "%s": %s', $regex, preg_last_error_msg()),
        );
    }

    return $result;
}

All four dispatcher strategies now call $this->matchRoute(...) instead of
preg_match(...) directly. Normal behavior is unchanged: 0 (no match) and
1 (match) are returned exactly as before. A false return (engine
failure) now throws a RuntimeException describing the failing regex and
preg_last_error_msg(), instead of being silently treated as "no match".

Empirical reproduction on unpatched HEAD

Using the exact route shape from issue #167 (a catastrophically backtracking
placeholder pattern combined, in the same regex chunk, with a cheap,
unrelated /{p:a+} route), dispatching a 30-character URI that the cheap
route should match:

URI: /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
preg_last_error(): 2 (Backtrack limit exhausted)
PREG_BACKTRACK_LIMIT_ERROR const: 2
Result class: FastRoute\Dispatcher\Result\NotMatched

preg_last_error() confirms the PCRE engine failed
(PREG_BACKTRACK_LIMIT_ERROR), yet the dispatcher returned NotMatched
for a URI that the cheap, unrelated /{p:a+} route matches on its own.
This reproduces with the library's default pcre.backtrack_limit
(1,000,000) at 30 characters; a lowered limit (ini_set('pcre.backtrack_limit', '1000'))
was additionally used in the regression test to make the failure
deterministic and independent of the host's PCRE build/limits.

Regression test

Added regexEngineFailureIsNotSilentlyTreatedAsNotFound() to
test/Dispatcher/DispatcherTestCase.php, the shared base class extended by
all four dispatcher test classes (GroupCountBasedTest, GroupPosBasedTest,
CharCountBasedTest, MarkBasedTest), so the fix is verified against all
four strategies.

@dualfroz
dualfroz force-pushed the dualfroz/fix-preg-error-not-notfound branch from af8bfe5 to 1f51800 Compare September 5, 2026 22:57
…ting them as no-match

All dispatcher strategies checked preg_match() results with `!== 1`,
which conflates a genuine no-match (0) with a PCRE engine failure
(false), e.g. PREG_BACKTRACK_LIMIT_ERROR from a catastrophically
backtracking route pattern. Because dynamic routes for the same
method are combined into shared regex chunks, one such route could
make the preg_match() call for the whole chunk fail, silently
turning an unrelated, cheap route in that chunk into a spurious
NOT_FOUND.

Add a shared matchRoute() helper on RegexBasedAbstract that calls
preg_match() and throws a RuntimeException (including
preg_last_error_msg()) when it returns false, while preserving the
existing 0/1 no-match/match behavior. Update GroupCountBased,
GroupPosBased, CharCountBased and MarkBased to use it.

Fixes nikic#167
@dualfroz
dualfroz force-pushed the dualfroz/fix-preg-error-not-notfound branch from 1f51800 to 8bb83fe Compare September 5, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant