Book: + | | , is 40 slides of exactly this: two parsers disagreeing about the same string, and the gap between them being the vulnerability.
PHP 8.5 ships two standards-compliant parsers as an always-on extension. This post is about where
parse_url()lies, and how to keep the new parser at the edge of your app where it belongs.
Where parse_url() lies
Start with the classic. A hostname with no scheme:
CODEvar_dump(parse_url('cdn.example.com/app.js'));
// array(1) { ["path"]=> "cdn.example.com/app.js" }
No
hostkey. The whole thing landed inpath. If your code didparse_url($url)['host']and compared it against an allowlist, you just gotnull, and whatever you do withnullis now the bug.
Next, a port that isn't a number:
CODEvar_dump(parse_url('https://example.com:80x/api'));
// bool(false)
The entire parse collapses to
falsebecause of two characters in the port. No component survives. Your validator has to handlefalse, and a lot of validators in the wild forget to.
Then there's the input
parse_url()accepts that it should reject. Control characters, whitespace inside the authority, mixed delimiters. It rarely errors. It reshuffles the string into an array and moves on, and the array often disagrees with what curl or a browser would do with the same bytes. That disagreement is the whole SSRF class.
Two parsers, on purpose
PHP 8.5 gives you two classes, and the split is deliberate.
Uri\Rfc3986\Urifollows RFC 3986. It's strict, it preserves what you gave it, and it's the right choice for URIs in the general sense: config values,urn:identifiers, anything that isn't specifically a web address headed for an HTTP client.
Uri\WhatWg\Urlfollows the WHATWG URL standard, the same algorithm browsers andfetch()run. It normalizes aggressively, handles internationalized domains, and reports soft errors. It's the right choice for any URL that a browser or HTTP client will resolve, because it parses the string the way that client will.
The reason both exist is the reason SSRF filters break: "the correct parse" depends on who's consuming the result. Match your validator's parser to your client's parser and the gap closes.
Parsing without the surprises
The RFC 3986 class has a constructor that throws and a static
parse()that returns null. Pick based on whether bad input is exceptional or expected.
CODEuse Uri\Rfc3986\Uri;
use Uri\InvalidUriException;
// Throws on invalid input.
try {
$uri = new Uri('https://example.com:80x/api');
} catch (InvalidUriException $e) {
// handled, typed, not a silent false
}
// Returns null on invalid input.
$uri = Uri::parse('https://example.com:80x/api');
if ($uri === null) {
// malformed port, rejected here
}
Compare that to the
parse_url()version, where invalid input isfalse, missing components are absent array keys, and nothing tells you which case you hit. The new API forces the decision at the parse site.
The getters return typed, nullable components:
CODE$uri = new Uri('https://api.example.com:8080/v1?id=7#top');
$uri->getScheme(); // "https"
$uri->getHost(); // "api.example.com"
$uri->getPort(); // 8080 (int, or null)
$uri->getPath(); // "/v1"
$uri->getQuery(); // "id=7"
$uri->getFragment(); // "top"
getPort()gives you anintornull. No more casting a string, no morefalse.
Raw versus normalized
This is the distinction that matters for security work. Every getter has a raw twin, and the object has both
toString()andtoRawString().
CODE$uri = new Uri('HTTPS://ExAmple.COM/sp%6Fnsor/');
$uri->getHost(); // "example.com" (normalized)
$uri->getRawHost(); // "ExAmple.COM" (as written)
$uri->toString(); // normalized form
$uri->toRawString(); // your exact input, untouched
When you compare a host against an allowlist, you want the normalized
getHost(), soExAmple.COMandexample.comdon't slip past a case-sensitivein_array(). When you need to echo back exactly what the user sent, you reach for the raw side. Having both, named clearly, means you stop hand-rollingstrtolower()normalization and getting it subtly wrong.
WHATWG for anything a client will touch
The WHATWG class is where the browser-compatibility payoff lives. Internationalized domains are the clearest example:
CODEuse Uri\WhatWg\Url;
$url = new Url('https://münchen.de/tickets');
$url->getAsciiHost(); // "xn--mnchen-3ya.de"
$url->getUnicodeHost(); // "münchen.de"
parse_url()hands backmünchen.deas-is. Feed that to a client expecting the ASCII/punycode host and you get a mismatch. The WHATWG parser gives you both representations, andgetAsciiHost()is the one your DNS resolver and TLS stack actually use.
Invalid input throws a typed exception carrying the validation errors:
CODEuse Uri\WhatWg\InvalidUrlException;
try {
$url = new Url('https://exa mple.com');
} catch (InvalidUrlException $e) {
foreach ($e->errors as $error) {
// structured UrlValidationError entries
}
}
For non-fatal issues, the constructor and
parse()take a by-reference$softErrorsargument so you can inspect problems that were recoverable but worth logging.
Building URLs safely
Both classes are immutable. The wither methods clone and return a new instance, so you compose a URL without ever concatenating strings:
CODE$base = new Uri('https://api.example.com/v1/users');
$next = $base
->withQuery('page=2&limit=20')
->withFragment('results');
$next->toString();
// https://api.example.com/v1/users?page=2&limit=20#results
Relative references resolve against a base the way the standard says they should, so you stop writing
rtrim($base, '/') . '/' . ltrim($path, '/'):
CODE$doc = new Uri('https://example.com/docs/guide/');
$doc->resolve('../api/reference')->toString();
// https://example.com/docs/api/reference
Keeping it at the edge
URL parsing is an I/O concern. It belongs in an adapter, next to the HTTP client, not scattered through your domain. The domain should receive a validated value object and never see a raw string.
Put the parser behind a port. Here's an outbound-URL guard that uses the WHATWG parser (matching what the HTTP client will resolve) to reject anything not on the allowlist:
CODEinterface OutboundUrl
{
public function toString(): string;
}
final class SafeOutboundUrl implements OutboundUrl
{
private function __construct(
private readonly string $url,
) {}
public static function forAllowedHosts(
string $candidate,
array $allowedHosts,
): self {
$parsed = Url::parse($candidate);
if ($parsed === null) {
throw new InvalidArgumentException(
'Unparseable URL rejected'
);
}
$host = $parsed->getAsciiHost();
if (!in_array($host, $allowedHosts, true)) {
throw new InvalidArgumentException(
"Host not allowed: {$host}"
);
}
return new self($parsed->toAsciiString());
}
public function toString(): string
{
return $this->url;
}
}
The validator parses the URL with the same algorithm the client uses, checks the ASCII host that DNS will actually resolve, and hands back a value object built from the normalized form. Your webhook dispatcher takes an
OutboundUrl, not astring, so no unvalidated URL reaches the network by this path. The SSRF check and the request share one parser, which closes the parser-disagreement gap. It does not close the whole SSRF class: DNS rebinding, where the host resolves to an internal IP after the check, and redirect following by the HTTP client are separate controls you still need.
Which class to reach for
- URLs headed for an HTTP client, a browser, or an SSRF guard:
Uri\WhatWg\Url. Match the client's algorithm. - URIs in the general sense, config identifiers, strict RFC 3986 work:
Uri\Rfc3986\Uri. - Bad input is expected (user forms, imported data):
::parse()and check fornull. - Bad input is a bug (internal config that must be valid): the constructor, and let
InvalidUriExceptionsurface. - Allowlist and comparison logic: the normalized getters (
getHost(),getAsciiHost()), never the raw ones.
parse_url()still works, and for pulling the scheme off a trusted internal string it's fine. The moment a URL comes from outside and gets sent somewhere, use the parser that agrees with the thing sending the request.
The
parse_url()era of "close enough" URL handling is ending. That's a good thing.
A URL parser is a textbook adapter concern: it deals with the messy outside world, and its job is to hand your domain a clean, validated value object it can trust. Keeping that translation at the boundary, so an
OutboundUrlnever arrives as a raw string, is the same discipline that lets you swap HTTP clients or frameworks without touching a use case. That boundary between the outside world and the domain is what Decoupled PHP is about, with chapters on ports, adapters, and the value objects that live at the seam.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.- URLs headed for an HTTP client, a browser, or an SSRF guard:
Ähnliche Beiträge
Auch interessante Nachrichten PHP 8.5's New URI Extension: Parsing URLs Without the parse_url() Footguns
Thematisch verwandte Begriffe: Extension, Parsing, URLs, Without · 6 Treffer
[webapps] Langflow 1.8.4 - Path Traversal to Remote Code Execution
JavaScript obfuscation: From party trick to phishing kit
[webapps] Langflow 1.10.0 - RCE
🚨 Typosquatting packages on PyPI are targeting 𝗿𝗲𝗾𝘂𝗲𝘀𝘁𝘀, one of the most widely used Python packages. Catch them before compromise.
Magento and Adobe Commerce StyleSmuggler 0-Day RCE Actively Exploited in Attacks
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...
Beiträge werden geladen ...
Videos werden geladen ...

SOCIAL SHARE CARD GENERATOR