‹ Back to Blog

Symfony's October Security Releases: Three Ways a Single Request Can Hurt You

Engineering PHP security

On October 14 and 15, Symfony shipped a coordinated set of maintenance releases across every supported branch: 5.4.54, 6.4.48, 7.4.21, and 8.1.9. Between them they close three CVEs, all rated high severity, and all with the same uncomfortable shape: a single HTTP request, no authentication, no special configuration, and the PHP process either burns a CPU core or falls over.

None of them is an injection or a data-leak bug. All three are resource-exhaustion bugs, the kind that denial-of-service tooling loves because the payload is tiny and the cost lands on your server. At Scout Monitoring we watch a lot of Symfony and Laravel apps, and the Symfony HTTP components sit under far more PHP apps than just the ones running the full framework. Here’s what each CVE does, how we reproduced it, and the exact version that closes it.

The Short Version

If you only read one thing: upgrade to 5.4.54, 6.4.48, 7.4.21, or 8.1.9, whichever matches your branch. Those four are the first releases that contain all three complete fixes. Two of the three CVEs had an initial patch that was either incomplete or introduced a regression, so an intermediate version like 7.4.19 or 8.1.7 fixes some of this but not all of it.

Branch Fully patched in Avoid stopping at
5.4 5.4.54 — (all three land together)
6.4 6.4.48 6.4.45–6.4.47
7.4 7.4.21 7.4.18–7.4.20
8.1 8.1.9 8.1.6–8.1.8

These are also published as per-component advisories, so if you pull individual packages rather than symfony/symfony, the ones that matter are symfony/http-foundation, symfony/yaml, and symfony/expression-language.

CVE-2026-108082: A Cache-Control Header Can Pin a CPU

This is the one that affects nearly everyone, because it’s reachable on any request. HeaderBag parses the Cache-Control header every time a Request object is built, and it does so through HeaderUtils::split(). The splitting pattern had a token branch that could also match whitespace, followed by a (?<!\s) lookbehind that forbade a token from ending on whitespace. A run of spaces placed before a separator could be divided among the iterations of the enclosing repetition in exponentially many ways, and the regex engine tried all of them before giving up on each.

We reproduced it against symfony/http-foundation 7.4.18 by building a Request whose Cache-Control header was no-cache followed by an ~8 KB value padded with runs of spaces before each comma:

Spaces before each separator 7.4.18 parse time 7.4.21 parse time
14 71 ms 2.7 ms
15 128 ms 0.2 ms
16 242 ms 0.2 ms
17 461 ms 0.2 ms

Each extra space roughly doubles the work. On the pre-fix version, a handful of these requests per second is enough to saturate a worker. And it gets worse past that point: from 18 spaces on, preg_match_all() hits pcre.backtrack_limit, gives up, and split() returns an empty array. We watched a header that clearly contained no-cache, max-age=0 come back parsed down to nothing — the caller can’t tell “the client sent no directives” apart from “the engine refused to finish,” so your caching logic silently sees an empty Cache-Control.

One caveat worth knowing. The first patch for this (6.4.46, 7.4.19, 8.1.7) fixed the CPU blow-up but broke splitting on a whitespace separator. We confirmed it on 7.4.20: HeaderUtils::split('foo bar', ' ') returns ["foo bar"] — a single element — instead of ["foo", "bar"]. If any of your code calls split() with a space as the separator (parsing a custom space-delimited header, say), that’s a behavior change hiding inside a security release. It’s corrected in 6.4.48, 7.4.21, and 8.1.9, where the same call correctly returns two elements again. That regression is the main reason to go all the way to the .48/.21/.9 releases rather than stopping at the first patched version.

CVE-2026-109413: Nested Inline YAML Crashes the Parser

Yaml\Parser bounds nesting depth almost everywhere — but the inline lexer, the code that reads {...} and [...] collections, recursed through lexInlineMapping() and lexInlineSequence() without entering the nesting counter the rest of the parser uses. A document that’s nothing but deeply nested inline brackets, [[[[...]]]], exhausted the call stack while being lexed. Depending on your PHP build and its stack settings, that’s a hard process crash, not a catchable exception.

We reproduced it with symfony/yaml 7.4.17. At moderate depth both versions raise a clean ParseException (“Maximum nesting depth of 128 exceeded”). But push the nesting far enough and the pre-fix version dies with a stack overflow — the process exits non-zero with no exception your code can handle — while 7.4.21 still returns the same orderly ParseException regardless of depth.

The reason this is a security bug and not just a footgun: anywhere you parse YAML a user can influence, this is a one-request kill. The advisory calls out #[MapRequestPayload] specifically — if the Yaml component is installed and an endpoint using that attribute doesn’t restrict acceptFormat, a single unauthenticated request with a YAML content type reaches the lexer. Fixed in 5.4.54, 6.4.45, 7.4.18, 8.1.6 and later.

CVE-2026-109449: A Deeply Nested Expression Crashes the Expression Parser

Same family, different component. The ExpressionLanguage parser built its node tree with no cap on depth. An expression made of tens of thousands of nested unary operators, array literals, or chained binary operators crashed the process — notably while the tree was being freed, which makes it look like an unrelated memory fault.

This matters because of where expressions get evaluated from untrusted input. #[Assert\ExpressionSyntax] runs the submitted value through ExpressionLanguage::lint(), so a form field that’s validated as an expression is a direct path to the parser. We reproduced it against symfony/expression-language 7.4.14: an expression of 60 parenthesized groups each holding 60 chained additions, like ((1 + 1 + ...) + 1 + ...), both linted and evaluated without complaint. On 7.4.21 the same input is rejected up front with SyntaxError: Expression is nested too deeply, the maximum nesting level is 256.

That 7.4.14 detail points at the other incomplete-fix story here. The first depth fix (6.4.45, 7.4.18, 8.1.6) counted recursion but missed the levels a chain of binary operators or property accesses adds above its first operand — exactly the shape above. So an app on 7.4.18 is protected against the nested-brackets form but still crashable by chained operators. The complete fix, which also checks the finished node tree’s depth, is in 6.4.48, 7.4.21, 8.1.9.

What to Check Before (and After) You Upgrade

For most apps the upgrade is clean — these are patch releases on an LTS branch. The two things to actually look at:

# Do you call split() with a whitespace separator? That return value changed.
grep -rn "HeaderUtils::split" . --include="*.php"

# Do you parse user-influenced YAML or expressions? These are the reachable paths.
grep -rnE "MapRequestPayload|Assert\\\\ExpressionSyntax|ExpressionLanguage" . --include="*.php"
grep -rn "Yaml::parse\|new Parser" . --include="*.php"

The first tells you whether the split() regression touches you. The second two tell you whether the YAML and expression DoS paths are actually reachable in your app, which decides how urgent this is: if you never parse untrusted YAML or expressions, only CVE-2026-108082 (the header one) really applies — and that one applies to everybody.

Then pin to the fully patched release for your branch, not the first one that mentions the CVE in its changelog.

Watching for the Symptom in Production

All three of these show up the same way in your metrics before you know the CVE exists: a cluster of requests that spend an unusual amount of time in the framework’s request-handling layer, or workers that restart more than they should. The header ReDoS in particular is pure CPU inside HeaderBag, with no slow query or external call to blame it on — the kind of thing that’s invisible in a log line and obvious in a response-time distribution.

Scout traces Symfony requests down to the layer, with deploys marked on your charts. A spike in time spent in request setup, or a step-change in CPU right after a dependency bump, is the signal that something like this is being exercised — and after you upgrade, the same charts show it flatten back out.

Running Symfony or Laravel in production? Start monitoring with Scout for free and see where every request spends its time, before and after an upgrade.