The Exploit
No authentication is required; an attacker only needs to control the CSS selector string passed into the affected symfony/css-selector parser.
php -r 'require "vendor/autoload.php"; use Symfony\Component\CssSelector\Parser\Parser; $parser = new Parser(); try { $parser->parse("div :scope"); echo "VULNERABLE\n"; } catch (Throwable $e) { echo "SAFE: ".$e->getMessage()."\n"; }'
When run against the vulnerable code, the malformed selector div :scope is accepted and the parser returns an AST instead of erroring. After the patch, the same selector raises SyntaxErrorException::notAtTheStartOfASelector('scope'), proving the parser now enforces valid :scope placement.
What the Patch Did
Before:
if (!$stream->getPeek()->isDelimiter(['('])) {
$result = new Node\PseudoNode($result, $identifier);
continue;
}
After:
if (!$stream->getPeek()->isDelimiter(['('])) {
$result = new Node\PseudoNode($result, $identifier);
if ('Pseudo[Element[*]:scope]' === $result->__toString()) {
$used = \count($stream->getUsed());
if (!(2 === $used
|| 3 === $used && $stream->getUsed()[0]->isWhiteSpace()
|| $used >= 3 && $stream->getUsed()[$used - 3]->isDelimiter([','])
|| $used >= 4
&& $stream->getUsed()[$used - 3]->isWhiteSpace()
&& $stream->getUsed()[$used - 4]->isDelimiter([','])
)) {
throw SyntaxErrorException::notAtTheStartOfASelector('scope');
}
}
continue;
}
The patch adds a parser-side syntax guard for the :scope pseudo-class. It checks whether the parsed pseudo is exactly :scope and then rejects it unless it appears at the beginning of a selector or immediately after a comma in a selector list.
Root Cause
This is CWE-20: Improper Input Validation. The attacker-controlled selector string enters the parser via Parser::parse(), reaches the Node\PseudoNode creation path for a bare pseudo-class, and crosses a trust boundary without context-sensitive validation. The parser previously accepted :scope anywhere a pseudo-class could appear, so invalid selectors such as div :scope were treated as syntactically valid by the underlying library.
Why It Works
The load-bearing line is the throw SyntaxErrorException::notAtTheStartOfASelector('scope'); inside the new if block. Without that line, the parser would still build the PseudoNode and continue, leaving invalid :scope placements accepted. The surrounding checks on $used and delimiter/whitespace positions are there to distinguish valid cases: a selector starting with :scope, a leading whitespace form, or a selector list entry after a comma.
Hardening Checklist
- update vendored dependencies promptly; keep
vendor/symfony/css-selectorcurrent rather than shipping an outdated parser library. - validate untrusted CSS selector input before passing it to a generic parser, and treat parser exceptions as security-relevant failures.
- if your plugin accepts user-provided selectors, reject or sanitize them server-side rather than relying solely on client-side validation.
- in WordPress plugins, protect AJAX endpoints with
current_user_can()andwp_verify_nonce()when selector input affects course structure or content. - use explicit syntax validation for special pseudo-classes like
:scopeinstead of assuming all pseudo-elements are equally valid in all positions.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13964