Description
X509Authenticator implements client-certificate (mTLS) authentication: the web server validates the client's certificate against a trusted CA, then passes the certificate's Subject DN (Distinguished Name: a string like CN=Alice,O=Example,emailAddress=alice@example.com) to Symfony via $SERVER['SSLCLIENTSDN']. Symfony extracts the user identifier from that string.
The extraction uses an unanchored regex that matches emailAddress= anywhere in the DN string: including inside the value of a different RDN (Relative Distinguished Name: one key=value component of the DN), such as CN. An attacker who can obtain a certificate from a trusted CA with a free-text CN can smuggle emailAddress=victim@target inside the CN value and be authenticated as the victim.
Resolution
The X509Authenticator now uses a regex that anchors the match to an RDN boundary (start of string, or following a , / / separator).
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Claude Mythos Preview (via Project Glasswing) for reporting the issue and providing the fix.
Description
Symfony Mailer selects a transport via the MAILERDSN environment variable / configuration (e.g. smtp://..., sendmail://..., native://default). SendmailTransport invokes the local sendmail binary and supports two modes: -bs (speak SMTP over stdin: the default) and -t (read the message on stdin, pass recipients as command-line arguments).
In -t mode, recipient addresses are appended to the sendmail command line without a -- end-of-options separator. A recipient address beginning with - (which Symfony\Component\Mime\Address accepts as valid) is therefore interpreted by sendmail as a command-line option rather than an address.
Resolution
The SendmailTransport transport now ensure -- is set before the list of recipients.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Claude Mythos Preview (via Project Glasswing) for reporting the issue and providing the fix.
Description
Symfony\Component\Yaml\Parser resolves YAML aliases (anchor) during parsing. Aliases that reference collections (arrays, stdClass, TaggedValue-wrapped collections) can themselves point to other collections containing aliases, creating exponential expansion at resolution time. A small input can blow up into a multi-gigabyte structure and exhaust memory: the classic "Billion Laughs" denial-of-service against any parser exposed to untrusted YAML.
Resolution
The Parser now counts collection alias resolutions in a shared ParserState object, with a default limit of 128, following the SnakeYAML model. Scalar aliases remain unrestricted since they cannot drive exponential growth. The limit is configurable via a new $maxAliasesForCollections argument on Parser::construct(), Yaml::parse() and Yaml::parseFile(). A new Yaml::PARSEEXCEPTIONONALIAS flag also rejects all aliases outright when parsing fully untrusted input.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Pietro Tirenna (Shielder) for reporting the issue and Nicolas Grekas for fixing it.
Description
Symfony\Component\Yaml\Parser::cleanup() strips the optional %YAML directive header, leading comments, and document start/end markers before parsing. The original regexes contained overlapping quantifiers, most notably '#^%YAML[: ][\d.]+.\n#u', whose [\d.]+ and . overlap on the dot, that exhibit catastrophic backtracking on crafted input. A single oversized %YAML directive header (or comment / document-marker line) makes the parser hang for an arbitrarily long time, denying service.
Resolution
The four regexes in Parser::cleanup() (YAML directive header, leading comments, document-start marker, document-end marker) have been rewritten with possessive quantifiers and unambiguous character classes so backtracking cannot occur.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Pietro Tirenna (Shielder) for reporting the issue and Nicolas Grekas for fixing it.
Description
symfony/dom-crawler provides the Crawler class for navigating HTML/XML documents with CSS/XPath selectors; symfony/browser-kit's HttpBrowser uses it to parse fetched pages.
Crawler::addXmlContent() sets DOMDocument::$validateOnParse = true before calling loadXML(). Setting validateOnParse re-enables libxml's DTD subset processing, including external entity resolution, even though LIBXMLNONET is passed. LIBXMLNONET blocks network fetches but not file:// entities. An attacker-supplied XML document with a SYSTEM "file:///etc/passwd" entity is therefore expanded.
Resolution
The Crawler::addXmlContent method does not set the validateOnParse flag anymore.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Claude Mythos Preview (via Project Glasswing) for reporting the issue and providing the fix.
Description
When a firewall is configured with form-login (or any authenticator using DefaultAuthenticationFailureHandler) and the failureforward: true option, the handler reads the failurepath parameter from the failing login request and uses it as the path of an internal subrequest dispatched through HttpKernelInterface::SUBREQUEST.
Symfony's Firewall::onKernelRequest listener intentionally skips subrequests under the assumption they are internally generated and trusted, which also means AccessListener (the listener that evaluates accesscontrol) does not run. Because the attacker controls the target of the subrequest, an unauthenticated POST to the check path with failurepath=/admin/whatever performs a local request forgery that executes the target controller outside the firewall perimeter and returns its response to the caller.
Applications that follow Symfony's recommended best practice of protecting administrative areas with broad accesscontrol rules (e.g. ^/admin requires ROLEADMIN) and expose read-only GET endpoints under that area (data exports, internal APIs, account views) are fully exposed: any such GET route can be read by an unauthenticated attacker without any developer misconfiguration, debug mode, or state-changing GET handler being required.
Resolution
DefaultAuthenticationFailureHandler no longer honors the request-supplied failurepath parameter when failureforward is enabled. The subrequest is always dispatched to the configured failurepath option (defaulting to loginpath), which is set by the application owner and not by the request. The redirect branch (failureforward: false) is unchanged because redirects re-enter the firewall on the next request and are not subject to this bypass.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Nguyen Ngoc Toan Thang (@a-tt-om) and Tran Quoc Tri Trung (@teebow1e) for reporting the issue, and Nicolas Grekas for providing the fix.
Description
Symfony\Bridge\Monolog\Command\ServerLogCommand (the server:log console command) is a development-time helper that opens a TCP listener and displays log records pushed to it by the application's logging pipeline. Two unsafe defaults combine into a remotely reachable PHP object-deserialization sink:
1. The listener binds to 0.0.0.0:9911 by default; it accepts connections on every interface, not only loopback. 2. Each received frame is processed as unserialize(base64decode($message)) without an allowedclasses allowlist, without authentication, and without any integrity check. The decoded value is then passed to displayLog(..., array $record) which assumes (without validating) that the result is an array.
Any host that can reach TCP port 9911 on a machine running server:log can therefore submit attacker-chosen serialized PHP payloads. The minimum impact is an unauthenticated denial of service (sending a non-array, e.g. serialize(new stdClass()), crashes the listener with a type error). Object injection with magic-method side effects (wakeup() / destruct() / etc.) is reachable before the array type-check fires; full remote code execution is environment-dependent and contingent on usable gadget chains in the autoload set of the target process.
Resolution
The server:log command no longer binds to all interfaces by default: the default --host is now 127.0.0.1:9911, requiring explicit opt-in to accept off-host traffic. Message decoding is gated by an unserialize() allowlist restricted to the Symfony\Component\VarDumper\Caster\ and Symfony\Component\VarDumper\Cloner\ classes that legitimately appear inside dumped log records; any other class is rejected and the record discarded.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Toàn Thắng and Sam Sanoop for reporting the issue and Nicolas Grekas for fixing it.
Description
Symfony\Component\Yaml\Parser is the entry point for parsing YAML strings into PHP values via Yaml::parse(). When the parser is exposed to attacker-controlled input, deeply nested mappings or sequences cause both the block-level (Parser::parseBlock()) and inline (Inline::parseSequence() / Inline::parseMapping()) parsers to recurse without a depth limit. A crafted document exhausts the PHP stack and crashes the worker.
Resolution
The Parser now tracks recursion depth in a shared ParserState object across both block-level and inline parsing, with a default limit of 128. The limit is configurable via a new $maxNestingLevel argument on Parser::construct(), Yaml::parse() and Yaml::parseFile().
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Pietro Tirenna (Shielder) for reporting the issue and Nicolas Grekas for fixing it.
Description
The Request class improperly interprets some PATHINFO in a way that leads to representing some URLs with a path that doesn't start with a /. This can allow bypassing some access control rules that are built with this /-prefix assumption.
Resolution
The Request class now ensures that URL paths always start with a /.
The patch for this issue is available here for branch 5.4.
Credits
We would like to thank Andrew Atkinson for discovering the issue, Chris Smith for reporting it and Nicolas Grekas for providing the fix.
Description
The Mailjet mailer bridge and the LOX24 SMS notifier bridge both ship webhook request parsers used to authenticate and decode the event callbacks each provider POSTs to an application's webhook endpoint. Their doParse(Request $request, #[\SensitiveParameter] string $secret) methods receive the configured webhook secret but never read it; they convert and return the payload unconditionally.
As a result, an application that wires up either webhook endpoint accepts any POST to that URL, even when a webhook secret is configured (the recommended setup). An attacker who knows the endpoint exists can submit forged event payloads, fake bounce / blocked / spam / open / click / delivery events, leading to suppression-list corruption, delivery-metrics fraud, etc.
Resolution
MailjetRequestParser::doParse() now rejects the request unless it carries the expected HTTP Basic credentials, Mailjet's webhook authentication mechanism, using a constant-time comparison. The configured webhook secret is matched against the credentials embedded in the Mailjet webhook URL as user:password (use :password when the URL has no username).
Lox24RequestParser::doParse() now rejects the request unless it carries an X-LOX24-Token HTTP header whose value matches the configured secret, using a constant-time comparison. The same token must be configured in the LOX24 dashboard under the callback settings.
When no secret is configured the behaviour is unchanged: webhook authentication remains opt-in, but it is now actually enforced once opted in.
The Mailjet patch is available here for branch 6.4.
The LOX24 patch is available here for branch 7.4 (the LOX24 bridge was introduced in 7.1 and is not present in 6.4).
Credits
Symfony would like to thank Himanshu Anand for reporting the issue, and Alexandre Daubois and Nicolas Grekas for providing the fixes.
Description
The Twilio SMS notifier bridge ships a webhook request parser used to authenticate and decode the status callbacks Twilio POSTs to an application's webhook endpoint. Its doParse(Request $request, #[\SensitiveParameter] string $secret) method receives the configured webhook secret but never reads it; it decodes and returns the payload unconditionally, ignoring the X-Twilio-Signature HMAC header Twilio sends with each request.
As a result, an application that wires up the Twilio webhook endpoint accepts any POST to that URL, even when a signing secret is configured (the recommended setup). An attacker who knows the endpoint exists can submit forged status payloads, fake delivered / failed / undelivered events, leading to delivery-metrics fraud, downstream automation triggers, etc.
Resolution
TwilioRequestParser::doParse() now requires and verifies the X-Twilio-Signature header (HMAC-SHA1 over the full request URL concatenated with the alphabetically-sorted POST parameters, base64-encoded, keyed with the Twilio account auth token) before further processing, using a constant-time comparison.
When no secret is configured the behaviour is unchanged: signature verification remains opt-in, but it is now actually enforced once opted in.
Applications behind a TLS-terminating reverse proxy must configure framework.trustedproxies and framework.trustedheaders so that Request::getUri() returns the public URL Twilio signed.
The patch for this issue is available here for branch 6.4.
Credits
Symfony would like to thank Himanshu Anand for reporting the issue and Nicolas Grekas for providing the fix.
Description
Symfony\Component\HttpClient\NoPrivateNetworkHttpClient is documented as a decorator that blocks requests to private networks by default. The list of blocked subnets (Symfony\Component\HttpFoundation\IpUtils::PRIVATESUBNETS on 6.4+, a private constant in NoPrivateNetworkHttpClient on 5.4) enumerates RFC1918, loopback, link-local and IPv4-mapped IPv6 (::ffff:0:0/96) prefixes, but omits the remaining IPv6 transition forms that can embed a private IPv4 destination: 6to4 (2002::/16, RFC 3056), Teredo (2001::/32, RFC 4380), NAT64 (64:ff9b::/96, RFC 6052 and 64:ff9b:1::/48, RFC 8215) and IPv4-compatible IPv6 (::/96, RFC 4291 §2.5.5.1).
IpUtils::checkIp6() is a pure bitwise CIDR comparison against the constants list and never extracts the embedded IPv4, so an attacker who can supply a URL writes the loopback / RFC1918 IPv4 target as e.g. http://[2002:7f00:1::]/ (6to4 → 127.0.0.1), http://[64:ff9b::7f00:1]/ (NAT64 → 127.0.0.1), http://[::7f00:1]/ (IPv4-compatible → 127.0.0.1) or http://[2001::1]/ (Teredo). IpUtils::isPrivateIp() returns false and NoPrivateNetworkHttpClient dispatches the request.
Real-world reachability of the embedded IPv4 depends on the deploy's IPv6 routing (6to4 tunnel interface, upstream NAT64 gateway, kernel handling of IPv4-compatible addresses), but the security boundary the decorator promises — the dispatch decision — is crossed regardless of whether the packet ultimately lands on the embedded IPv4.
Resolution
The private-subnet list now includes ::/96, 2002::/16, 2001::/32, 64:ff9b::/96 and 64:ff9b:1::/48. Blanket blocking of these prefixes matches the policy applied by Chromium and Mozilla's Private Network Access; server-side HTTPS APIs are not legitimately published on these prefixes.
The patches for this issue are available here for branch 5.4 and here for branch 6.4 (and forward-ported to 7.4, 8.0 and 8.1).
Credits
Symfony would like to thank tonghuaroot for reporting the issue and Nicolas Grekas for providing the fix.
Description
Symfony\Component\Cache\Adapter\PdoAdapter is the PDO-backed cache adapter. Its clear($prefix) method (inherited from AbstractAdapterTrait) is documented to delete cache items whose key starts with $prefix.
In the non-versioning code path, the caller-supplied $prefix is concatenated into $namespace = $this->namespace.$prefix and passed to PdoAdapter::doClear(), which builds:
sql DELETE FROM <table> WHERE <idcol> LIKE '<namespace>%'
The value is interpolated directly into the SQL text and executed with PDO::exec(): $namespace is not bound. A caller able to influence $prefix can break out of the literal and inject SQL, expanding deletion scope from the intended prefix to arbitrary rows, or otherwise reshape query semantics.
Most applications don't expose clear($prefix) to untrusted input directly, but the contract of the method is to safely accept any prefix string, so the lack of escaping is a defect of the adapter itself.
Resolution
AbstractAdapterTrait::clear() now rejects any $prefix containing characters outside [-+.A-Za-z0-9]: when an invalid prefix is supplied, the method logs a warning and returns false instead of reaching the SQL layer. This blocks quotes, %, null bytes and other characters that would let an attacker break out of the LIKE literal.
The patch for this issue is available here for branch 5.4.
Credits Symfony would like to thank secsyscodex for reporting the issue and Nicolas Grekas for fixing it.
Description
Symfony\Component\Mime\Header\ParameterizedHeader (and the related parameter handling reachable from Symfony\Component\Mime\Header\Headers) is responsible for serializing structured headers such as Content-Type and Content-Disposition, which carry key=value parameters (e.g. Content-Disposition: attachment; filename="x").
RFC 2045 / RFC 5322 require parameter names to be tokens: a restricted ASCII subset that excludes whitespace, CR/LF, and the tspecials set. Symfony's parameter handling validates and properly encodes parameter values, but does not validate parameter names: the supplied name is emitted verbatim into the serialized header.
A caller that derives a parameter name from untrusted input, e.g. an application that lets a user influence a Content-Disposition parameter name, can include \r\n or other non-token bytes inside the name, terminating the current header and injecting additional headers in the rendered message. This is the classic CRLF / header-injection primitive applied to the parameter-name slot.
Resolution
ParameterizedHeader now rejects parameter names that contain bytes outside the RFC token character class.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Fabian Fleischer for reporting the issue and Alexandre Daubois for fixing it.
Description
Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\UrlAttributeSanitizer::getSupportedAttributes() enumerates the attribute names whose values are scrubbed through UrlSanitizer::sanitize() (scheme and host allow-lists, javascript: rejection, BiDi check, etc.). The list is ['src', 'href', 'lowsrc', 'background', 'ping', 'action', 'formaction', 'poster', 'cite']. Other URL-bearing attributes are absent: <object data=…>, <applet codebase=…>, <applet archive=…> and <object archive=…>, <iframe longdesc=…> and <img longdesc=…>. When an integrator opts these elements/attributes in via allowElement('object', ['data']), allowElement('applet', ['codebase']), etc., or via allowAttribute(), no URL sanitization runs: data="javascript:alert(1)" and similar payloads ship through unchanged into the output, enabling stored XSS.
<meta http-equiv="refresh" content="0; url=…"> is the same class of bug routed differently: the URL is embedded inside a multi-field content attribute that the per-attribute sanitizer cannot detect from the attribute name alone. Integrators who enable <meta> with the content attribute (e.g. via allowStaticElements()) see content="0; url=javascript:alert(1)" pass through, producing a refresh-driven navigation to a javascript: URL.
Default configurations are not affected: <object>, <applet> and <iframe> are not in W3CReference::BODYELEMENTS and <meta> requires an explicit opt-in to <head> context. The vulnerability surface is integrators who explicitly allow any of those elements together with the listed URL-bearing attributes.
Resolution
UrlAttributeSanitizer now also routes data, codebase, archive and longdesc through UrlSanitizer::sanitize(). A new MetaRefreshAttributeSanitizer registered as a default attribute sanitizer detects the <delay>; url=<url> syntax inside <meta content>, sanitizes the embedded URL, and drops the attribute if the URL is rejected; non-refresh meta content values are passed through unchanged.
The patches for this issue are available here for branch 6.4 (and forward-ported to 7.4, 8.0 and 8.1).
Credits
Symfony would like to thank Scott Arciszewski (Trail of Bits) for reporting the issue and Nicolas Grekas for providing the fix.
Description
Symfony\Component\HtmlSanitizer\TextSanitizer\UrlSanitizer::parse() rejects URLs containing raw Unicode explicit-direction BiDi formatting characters (U+202A–U+202E, U+2066–U+2069) as a defense against visual-spoofing of the rendered href. The check covers only the raw UTF-8 forms of those code points: the percent-encoded forms (%E2%80%AE for U+202E, %E2%81%A6 for U+2066, etc.) are not matched by the deny regex, survive league/uri's parse/build cycle, and are re-emitted unchanged in the sanitized URL. Any downstream consumer that decodes the link before display — phishing-detection filters that compare urldecode($href) against a domain allow-list, audit-log dashboards that show a decoded form for readability, hover-tooltip previews, federated/syndicated content where the decoder lives on the consuming side — restores the BiDi character and the visual spoof that the original defense was filed to prevent.
The same UrlSanitizer::parse() carries an ASCII-only /\s/ whitespace check (no /u modifier) intended as a backstop against malformed URLs. Without the /u modifier, PCRE's \s matches only ASCII whitespace, so Unicode whitespace characters — NBSP (U+00A0), the zero-width no-break space / BOM (U+FEFF), line/paragraph separators (U+2028, U+2029), ogham space (U+1680), the U+2000–U+200A en/em quad family, narrow / medium / ideographic spaces (U+202F, U+205F, U+3000) and NEL (U+0085) — pass through unchanged in both raw and percent-encoded forms. In hostname positions they enable lookalike spoofs (example<NBSP>.com); in path/query/fragment they enable allow-list drift when a downstream consumer strips whitespace before comparison.
Resolution
UrlSanitizer::parse() now denies BiDi formatting marks together with Unicode whitespace and the zero-width no-break space, in both the raw input and the percent-decoded form of each parsed URL component (user, pass, host, path, query, fragment). ASCII space remains tolerated in path/query/fragment via the existing percent-encoding step.
The patches for this issue are available here for branch 6.4 (and forward-ported to 7.4, 8.0 and 8.1).
Credits
Symfony would like to thank Scott Arciszewski (Trail of Bits) for reporting the issue and Nicolas Grekas for providing the fix.
Description
Symfony\Component\Routing\Generator\UrlGenerator::doGenerate() percent-encodes . and .. path segments so that the generated URL still resolves to the originating route after RFC 3986 §5.2.4 dot-segment removal (which strict RFC-3986 consumers — routers, reverse proxies, HTTP clients — perform before percent-decoding).
The encoding was implemented as strtr($url, ['/../' => '/%2E%2E/', '/./' => '/%2E/']) plus a trailing-segment fixup. strtr advances past the trailing / of each match, so the next dot-segment in a chained sequence was left unescaped:
| Input | Output (before fix) | Expected | | -------------------- | ---------------------------------------- | ----------------------------------- | | /../../../ | /%2E%2E/../%2E%2E/ | /%2E%2E/%2E%2E/%2E%2E/ | | /foo/../../../bar | /foo/%2E%2E/../%2E%2E/bar | /foo/%2E%2E/%2E%2E/%2E%2E/bar |
When a route exposes a parameter constrained by a permissive requirement (.+, ., or similar) that accepts dots and slashes, attacker-controlled chained .. or . segments produce a generated URL that, under strict RFC 3986 normalization, collapses to a different path than the originating route. The Twig path() / url() helpers and any server-side use of UrlGenerator are affected. Same class of route round-trip integrity issue as CVE-2026-45065.
Note: WHATWG-conformant browsers treat %2E/%2E%2E as dot-segments during URL parsing, so the encoding never protected browser-side traversal. The defense exists for RFC-3986-conformant consumers; restoring it for chained segments closes the gap there.
Resolution
UrlGenerator now matches every /. or /.. dot-segment in a single left-to-right pregreplacecallback pass using a lookahead that does not consume the trailing /, so adjacent dot-segments are encoded correctly.
The patches for this issue are available here for branch 5.4 (and forward-ported to 6.4, 7.4, 8.0 and 8.1).
Credits
Symfony would like to thank Alex Pott for reporting the issue and Nicolas Grekas for providing the fix.
Description
Symfony routes can declare a requirements regex per path parameter, e.g. a route /{locale}/blog with requirements: { locale: 'en|fr|de' }. The Twig path() / url() helpers (backed by UrlGenerator) validate supplied parameter values against that regex before building the URL.
UrlGenerator constructs the validation pattern as '#^'.$req.'$#', where $req is the raw requirement string. For a requirement expressed as an alternation, e.g. locale: 'ar|bg|...|vi|...|zhCN' (very common), ^ and $ anchor only the first and last alternatives, so any middle alternative matches as an unanchored substring. A value like /evil.com satisfies the requirement (because it contains vi), and the generated path becomes //evil.com/...: a protocol-relative URL the browser navigates off-site.
Resolution
The UrlGenerator class now wraps the requirement in a non-capturing group so the ^ and $ anchors apply to the whole alternation.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Claude Mythos Preview (via Project Glasswing) for reporting the issue and providing the fix.
Description
Symfony\Component\HtmlSanitizer\TextSanitizer\UrlSanitizer::parse() (used by UrlSanitizer::sanitize() and therefore by every HtmlSanitizer config that allows links or media) accepts URLs that contain Unicode explicit-direction BiDi formatting characters: U+202A–U+202E (LRE / RLE / PDF / LRO / RLO) and U+2066–U+2069 (LRI / RLI / FSI / PDI). These characters are passed through unchanged into the href / src attributes produced by HtmlSanitizer. When the resulting HTML is rendered in a browser, the override characters reverse or alter the visual ordering of the URL text, so the displayed link can differ arbitrarily from the actual destination: a classic visual-spoofing / phishing primitive against viewers of sanitized content.
Resolution
UrlSanitizer::parse() now rejects URLs containing the explicit-direction BiDi formatting code points (U+202A–U+202E, U+2066–U+2069) before invoking the underlying URL parser. As an unrelated companion fix in the same patch, spaces inside path/query/fragment are now percent-encoded rather than rejected outright, while spaces in the scheme/authority remain rejected by the post-encoding whitespace check.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Himanshu Anand for reporting the issue and Nicolas Grekas for providing the fix.
Description
symfony/html-sanitizer lets applications sanitise untrusted HTML. The configuration methods allowLinkHosts([...]) and allowLinkSchemes([...]) are intended to restrict <a href> targets to an allowlist of hosts/schemes; allowMediaHosts() / allowMediaSchemes() do the same for <img src> etc.
Three distinct bypasses allow a content author to smuggle off-allowlist URLs past these checks. First, UrlSanitizer::parse() parses the input following RFC-3986, while browsers follow the WHATWG URL Standard which normalises \ to / before parsing the authority of "special" schemes; so an input like https://evil\@trusted.com/ parses with host trusted.com server-side but navigates to https://evil/ in the browser. Second, WHATWG collapses any run of / after the scheme into //, while RFC-3986 does not; so https:/evil.com/ and https:///evil.com/ parse as host-less (skipping the host allowlist) but resolve to evil.com in the browser. Third, UrlAttributeSanitizer checks 'a' === $element to route to the link policy and falls through to the media policy otherwise, but <area> is a navigable hyperlink equivalent to <a>; so <area href> was sanitised against the media policy (which typically allows data: and may have no host allowlist), bypassing allowLinkHosts() / allowLinkSchemes() entirely.
Resolution
UrlSanitizer::sanitize() now rejects URLs that contain a backslash or that use a special scheme (http, https, ftp, ws, wss) followed by a single slash or three slashes before parsing, eliminating the parser-differential bypasses. UrlAttributeSanitizer now applies the link policy to both <a> and <area> elements.
The patch for this issue is available here for branch 5.4.
Credits
Symfony would like to thank Claude Mythos Preview (via Project Glasswing) for reporting the issue and providing the fix.
Description
symfony/html-sanitizer lets applications sanitise untrusted HTML. UrlAttributeSanitizer is the visitor responsible for validating URL-valued attributes and stripping dangerous schemes from them; it runs on every element regardless of configuration. Whether an attribute is kept is decided by the element/attribute allow-list; validating the scheme of a URL attribute is solely UrlAttributeSanitizer's responsibility.
UrlAttributeSanitizer::getSupportedAttributes() returned only ['src', 'href', 'lowsrc', 'background', 'ping']. The HTML URL-valued attributes action (<form>), formaction (<button>, <input type=image>), poster (<video>) and cite (<blockquote>, <q>, <del>, <ins>) were missing from that list, so DomVisitor never invoked scheme validation for them. As a result, when a configuration admits one of those attributes, a javascript: URI in it survived sanitisation.
Conditions for exploitation
allowSafeElements() is not affected: <form> and the formaction attribute are both flagged unsafe in W3CReference, and allowElement('form') resets the element's attribute list. Reaching the vulnerable attributes requires a deliberately permissive configuration, for example:
<form> + action: allowElement('form', ''), allowElement('form', ['action', …]), allowElement('form')->allowAttribute('action', 'form'), or the allowStaticElements() preset (whose docblock already warns the output "may still contain other dangerous behaviors"); <button> / <input type=image> + formaction: allowElement(…, ''), allowAttribute('formaction', …), or allowStaticElements(); <blockquote> / <q> / <del> / <ins> + cite, or <video> + poster: similarly via '', allowAttribute(), or allowStaticElements().
For the action / formaction cases the victim must additionally submit the form or click the button.
Resolution
UrlAttributeSanitizer now also handles action, formaction, cite and poster. action / formaction / cite are validated against the link schemes (like <a href>, so javascript: is rejected and data: is dropped too); poster is validated against the media schemes (so data: images keep working). The behaviour of <a href> and <img src> is unchanged.
One behaviour change to be aware of: a relative action="/submit" on an allowed <form> is now dropped by default (the same as <a href> / <img src> today); ->allowRelativeLinks() re-enables it.
The patch for this issue is available here for branch 6.4.
Credits
Symfony would like to thank Himanshu Anand and Rémi Pelloux for reporting the issue and Nicolas Grekas for providing the fix.