A ^…$ regex accepts one trailing newline in PHP and Python — the strict anchors are /D, \z and re.fullmatch

While hardening the input checks on a mobile game’s score server, I fed the player-token validator a 33-character string — 32 hex digits plus a trailing newline — expecting a rejection. preg_match('/^[0-9a-f]{32}$/', $token) returned 1. There is no /m flag anywhere in that pattern.

TL;DR — In PCRE and in Python’s re, $ matches just before a string-final newline as well as at the very end, even without the multiline flag, so a ^…$ validator accepts exactly one trailing \n. The strict forms are the D modifier or \z in PHP, and re.fullmatch or \Z in Python.

The setup

The server is a small PHP API behind a mobile puzzle game: the app posts a score over HTTP, the server verifies it and stores it for a leaderboard. Two inputs matter here. The player token is an anonymous identifier the app generates once and sends with every request — by contract, exactly 32 lowercase hexadecimal characters. The move list is the sequence of moves that produced the score, sent as comma-separated tokens such as 3>1 (« pour container 3 into container 1 »), so the server can replay the game and check the claimed score instead of trusting it. Both were validated with anchored regexes:

preg_match('/^[0-9a-f]{32}$/', $token);   // player token
preg_match('/^(\d+)>(\d+)$/', $move);     // one move

A trailing newline is exactly what these validators meet in the wild: file_get_contents() on a file saved by an editor (most append a final newline), fgets() which keeps the \n it read, curl --data-binary @token.txt, and half of all copy-pastes.

What I expected

I read « no /m » as « ^ and $ anchor at the absolute start and end of the string ». Under that model, /^[0-9a-f]{32}$/ matches 32-character strings and nothing else, and trimming whitespace before validating is hygiene, not a requirement.

What actually happens

$s = "abcdef01234567890123456789abcdef\n";        // 32 hex + newline, 33 chars
var_dump(preg_match('/^[0-9a-f]{32}$/',  $s));    // int(1)  ← accepted
var_dump(preg_match('/^[0-9a-f]{32}$/D', $s));    // int(0)
var_dump(preg_match('/^[0-9a-f]{32}\z/', $s));    // int(0)
var_dump(preg_match('/^[0-9a-f]{32}\Z/', $s));    // int(1)  ← capital Z is not strict

Python’s re behaves the same way:

import re
re.match(r"^\d+$", "12\n")        # <re.Match object; span=(0, 2), match='12'>
re.fullmatch(r"\d+", "12\n")      # None
re.match(r"^\d+\Z", "12\n")       # None — in Python, capital \Z is the strict one
re.match(r"^\d+\z", "12\n")       # None on Python 3.14; \Z is the portable spelling

I ran all of the above on PHP 8.5.4 and Python 3.14.4.

The hole is narrow, and I probed its edges. Exactly one newline is forgiven, and only at the very end: "12\n\n" fails against /^\d+$/ (int(0)), and so does "12\nx" — without /m, $ never matches before an interior newline. Narrow, but shaped exactly like real input.

Diagram of the string "…abcdef\n": the dollar anchor matches both before the final newline and at the end of the string, \z matches only at the end; verdict tables show PHP preg_match and Python re accepting the lenient pattern and rejecting the strict ones.

The fix

Two equivalent strict forms in PHP — pick one and use it on every validator:

preg_match('/^[0-9a-f]{32}$/D', $token) === 1;  // D: $ matches end of string only
preg_match('/^[0-9a-f]{32}\z/', $token) === 1;  // \z: absolute end-of-subject anchor

And in Python:

re.fullmatch(r"[0-9a-f]{32}", token)   # anchors both ends, no newline exception
re.match(r"^[0-9a-f]{32}\Z", token)    # \Z: absolute end — in Python only

Mind the case trap when moving between the two languages. PCRE’s \Z still matches before a final newline (the int(1) above); only lowercase \z is absolute. Python spells the absolute anchor \Z, and its 3.14 also accepts \z. Same letters, opposite strictness across engines. The full modifier list is in the PHP PCRE modifiers reference; the anchors are in the Python re docs.

Why it works

The behaviour is inherited from Perl. Perl’s $ was designed for line-oriented scripts that read lines with their terminator: the read loop hands you "data\n", and /^\d+$/ was meant to match it without a chomp first. So $ (and \Z) match either at the end of the subject or immediately before a newline that is the subject’s last character. PCRE copied Perl, preg_match exposes PCRE, and Python’s re kept the same convention. PHP’s D modifier maps to PCRE’s DOLLAR_ENDONLY option, which removes the newline exception; the documentation notes it is ignored when /m is set, since /m redefines $ entirely.

Whether the forgiven \n matters depends on what the value does next. My token went into a database as an exact-match key: "abc…def" and "abc…def\n" are different strings, so one player could hold two leaderboard rows, and a later lookup with the trimmed form would miss the stored one. For the move token, the validator’s whole job was to guarantee the replay parser only ever sees the exact grammar digits>digits — a validator that vouches for a byte it never examined breaks that contract. It is the same failure family as a tag-stripping regex that runs before entity decoding: the check and the consumer disagree about what the string is.

What I did not test

  • Only PHP 8.5.4 (bundled PCRE2) and Python 3.14.4. The PCRE documentation describes the same default for older versions, but I did not run them.
  • "12\r\n" — my inputs were LF-only; I did not observe whether $ steps over a final CRLF pair.
  • Whether Python versions before 3.14 reject the \z spelling with an error — I only ran 3.14.
  • Other engines. JavaScript’s RegExp is commonly said to lack the newline exception; I did not verify that this session.

Facts

context: PHP score API validating a 32-hex player token and digits>digits move tokens with anchored regexes
problem: /^[0-9a-f]{32}$/ without /m matches the 33-char string ending in \n; Python re.match(r"^\d+$", "12\n") matches too
solution: PCRE — add the D modifier or anchor with \z; Python — re.fullmatch or \Z (PCRE \Z is NOT strict)
verified_on: 2026-08-17
applies_to: [PHP 8.5.4 (PCRE2), Python 3.14.4]
does_not_apply_to: [two or more trailing newlines (rejected anyway), interior newlines without /m (rejected anyway), JavaScript RegExp (not verified)]

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut