别再手写 URL 解析器了:PHP 8.5 URI 扩展让 URL 处理更安全、更干净
parse_url() 能用,但不够用
多年来,PHP 开发者处理 URL 的方式大同小异:
- 用 parse_url() 拆分各部分
- 用 rawurlencode() / urlencode() 转义
- 用字符串拼接重建最终 URL
- 遇到"奇怪"输入时,再上几个正则
大多数情况下这套流程能跑通。问题在于,URL 处理恰恰是那种"在边角情况下出问题"的领域:编码、fragment、userinfo、国际化域名、"等价但不相同"的 URL,以及各种只在生产日志里才冒出来的边缘场景。
PHP 8.5 提供了一个内置替代方案:一个始终可用的 URI 扩展,提供 API 来按照 RFC 3986 和 WHATWG URL 标准解析、修改 URL/URI。
如果"标准"二字让你觉得抽象,这里给出实际含义:
- 拿到 URI/URL 对象,而不是数组 + 字符串拼接。
- 用安全的组件 getter 和不可变的 with*() 方法。
- 你可以选择 RFC 3986 行为(严格 URI,"原始 vs 规范化解码")或浏览器风格的 WHATWG 行为(Unicode/IDNA、软错误、自动编码)。
本文是一篇实战教程,重点讲:
- 为什么手动解析容易出错,
- 新 URI 对象怎么用,
- 如何安全地修改/规范化,
- 在重定向、签名链接等安全敏感场景下如何使用。
不讲升级指南,不讲废弃清单——只讲 URI 扩展。
原文 别再手写 URL 解析器了:PHP 8.5 URI 扩展让 URL 处理更安全、更干净
手动 URL 解析的问题
parse_url() 不解码(而且很容易忘)
PHP 的 parse_url() 返回各组件,但不会 URL 解码它们。
也就是说:- $u = parse_url("https://example.com/t%65st?name=Ali%63e#fr%61g");
- var_dump($u['path']); // "/t%65st"
- var_dump($u['query']); // "name=Ali%63e"
- var_dump($u['fragment']); // "fr%61g"
复制代码 如果你在比较路径或应用路由规则时没有统一解码/规范化,可能会把等价的 URI 当成不同的来处理。
更糟的是:团队往往混用:
- 有些地方用解码后的值,
- 有些地方用原始值,
- 再加上散落在各处的临时解码逻辑。
这种混乱很容易埋下隐蔽 bug 和安全隐患。
字符串拼接容易拼出"差一点对"的 URL
手动重建 URL 容易犯的错:
- 漏掉 ? 或 #
- 重复编码
- 完全没编码
- 编码错了东西(比如把整个 query string 编码而不是只编码 value)
- 丢失或打乱参数顺序
- 空 query/fragment 处理不当
一个常见的"差一点对"函数:- function addQueryParam(string $url, string $key, string $value): string
- {
- $parts = parse_url($url);
- $query = $parts['query'] ?? '';
- $query .= ($query === '' ? '' : '&') . $key . '=' . urlencode($value);
- $out = $parts['scheme'] . '://' . $parts['host'] . ($parts['path'] ?? '');
- if ($query !== '') {
- $out .= '?' . $query;
- }
- if (isset($parts['fragment'])) {
- $out .= '#' . $parts['fragment'];
- }
- return $out;
- }
复制代码 看起来没问题。直到遇到:
- 没有 scheme/host 的 URL(相对 URL),
- 带 userinfo/port 的 URL,
- 已经编码过的值,
- 需要 rawurlencode() 规则(RFC 3986)的参数,
- 应该原样保留的 fragment。
等价的 URL 不一定是相同的字符串
下面这些可以指向同一个资源,但字符串不同:
- scheme/host 大小写不同(HTTPS://EXAMPLE.com)
- path 中 %65 vs e(/t%65st vs /test)
- path 中的点号段(/foo/../bar/)
- 默认端口(https 的 :443)
如果你把 URL 当成纯字符串处理,要么:
- 缓存/路由出现诡异问题,
- 要么安全检查被绕过,因为你比较的是"错误的表示形式"。
IDNA(国际化域名)还涉及安全问题
如果你允许用户提交 URL,国际化域名可能是合法的——但也可能造成混淆。域名可以用 Unicode 或 punycode(ASCII 形式)表示。RFC 讨论中明确指出人为风险:punycode 域名在 Unicode 渲染时可能看起来像一个熟悉的、但实际不同的域名。
这不是你想用正则"手动处理"然后祈祷没问题的事。
URI 扩展的概念:URI 对象 + 两套标准
PHP 8.5 的 URI 扩展提供两个主要类:
- Uri\Rfc3986\Uri(RFC 3986 合规,严格 URI 规则)
- Uri\WhatWg\Url(WHATWG URL 合规,浏览器风格的 URL 规则)
配套的类型包括:
- Uri\InvalidUriException
- Uri\WhatWg\InvalidUrlException
- Uri\WhatWg\UrlValidationError 和 UrlValidationErrorType
- Uri\UriComparisonMode(用于比较)
一个关键设计决策:两个实现都是 readonly 且以不可变方式使用——withPath()、withQuery() 等方法返回新实例。
另一个要点:这个扩展在 PHP 8.5 中始终可用,底层由以下库驱动:
- uriparser(用于 RFC 3986)
- Lexbor(用于 WHATWG URL)
所以你不需要第三方库就能获得合理的 URL 处理能力。
创建和解析 URI:从字符串到组件
最简单的情况:解析一个 HTTP URL 并访问各部分
RFC 3986:- use Uri\Rfc3986\Uri;
- $uri = new Uri("https://php.net/releases/8.5/en.php");
- echo $uri->getScheme(); // "https"
- echo $uri->getHost(); // "php.net"
- echo $uri->getPath(); // "/releases/8.5/en.php"
复制代码 WHATWG:- use Uri\WhatWg\Url;
- $url = new Url("https://example.com/path?query=1#frag");
- echo $url->getScheme(); // "https"
- echo $url->getAsciiHost(); // "example.com"
- echo $url->getPath(); // "/path"
- echo $url->getQuery(); // "query=1"
- echo $url->getFragment(); // "frag"
复制代码 注意:WHATWG 的 getter 故意不返回分隔符(如 : / ? #)。
构造函数抛异常;parse() 返回 null
两个实现都支持两种解析风格:
- 构造函数:无效时抛异常
- parse():无效时返回 null
RFC 3986 行为:- use Uri\Rfc3986\Uri;
- use Uri\InvalidUriException;
- try {
- $uri = new Uri("not a uri");
- } catch (InvalidUriException $e) {
- // 拒绝输入
- }
- $uri = Uri::parse("not a uri");
- var_dump($uri); // null
复制代码 WHATWG 行为提供更丰富的错误信息:- use Uri\WhatWg\Url;
- use Uri\WhatWg\InvalidUrlException;
- try {
- $url = new Url("invalid url");
- } catch (InvalidUrlException $e) {
- // $e->errors 包含 UrlValidationError 实例
- }
- $errors = [];
- $url = Url::parse("invalid url", null, $errors);
- var_dump($url); // null
- var_dump($errors); // UrlValidationError 数组
复制代码 这种区分(硬错误 vs 软错误 vs parse-and-return-null)在 RFC 中有详细说明。
Base URL 和引用解析(相对 → 绝对)
有了这个功能,你不再需要手动检查是否以 / 开头然后拼接。
在构造函数或 parse() 中使用 base URL:- use Uri\Rfc3986\Uri;
- $base = new Uri("https://example.com");
- $uri = new Uri("/foo", $base);
- echo $uri->toString(); // "https://example.com/foo"
复制代码 WHATWG 类似:- use Uri\WhatWg\Url;
- $base = new Url("https://example.com");
- $url = Url::parse("/foo", $base);
- echo $url->toAsciiString(); // "https://example.com/foo"
复制代码 扩展还提供了便捷的 resolve() 方法,以当前对象作为 base。
安全修改:设置/替换 path、query、fragment(不可变)
拿到对象后,可以用 with*() 方法修改组件。这些方法返回新实例,原对象保持不变。
RFC 3986:withPath()、withQuery()、withFragment()
- use Uri\Rfc3986\Uri;
- $uri = new Uri("https://example.com/products?sort=asc#top");
- $updated = $uri
- ->withPath("/products/123")
- ->withQuery("sort=desc&ref=home")
- ->withFragment("reviews");
- echo $uri->toString(); // 原对象不变
- echo $updated->toString(); // 修改后的
复制代码 RFC 3986 提供"原始"和"规范化解码"两种 getter(下一节详述),以及 toString() 和 toRawString() 方法。
WHATWG:同样的思路,外加 ASCII/Unicode 字符串输出
- use Uri\WhatWg\Url;
- $url = new Url("https://example.com/");
- $new = $url
- ->withPath("/search")
- ->withQuery("q=php+8.5")
- ->withFragment("results");
- echo $new->toAsciiString();
- echo $new->toUnicodeString();
复制代码 WHATWG 有 toAsciiString() 和 toUnicodeString() 两种输出,分别用于机器处理和人类展示。
一个小但重要的行为:输入中的分隔符
使用 WHATWG 时,如果你在设置 query/fragment 时不小心包含了 ? 或 #,它会把它们当作分隔符并去掉:- use Uri\WhatWg\Url;
- $url = new Url("https://example.com/");
- $url = $url->withQuery("?foo");
- $url = $url->withFragment("#bar");
- echo $url->getQuery(); // "foo"
- echo $url->getFragment(); // "bar"
复制代码 这个行为在文档中有明确说明。
规范化与编码:避免重复编码和"奇怪字符"
URI 扩展的价值不止于 API 设计——它让你能控制 URL 的表示形式。
RFC 3986 给你两种表示:原始 vs 规范化解码
对于大多数组件,Uri\Rfc3986\Uri 暴露:
- raw:解析器给出的形式(最接近原始输入)
- 规范化解码:规范化 + 百分号解码,意在成为规范形式且可往返转换
RFC 解释了为什么需要两种形式以及何时使用——签名方和 API 客户端通常偏好 raw,而路由/缓存通常偏好规范化解码。
具体效果:- use Uri\Rfc3986\Uri;
- $uri = new Uri("https://%61pple:p%61ss@ex%61mple.com:433/foob%61r?%61bc=%61bc#%61bc");
- echo $uri->getRawHost(); // "ex%61mple.com"
- echo $uri->getHost(); // "example.com"
- echo $uri->getRawPath(); // "/foob%61r"
- echo $uri->getPath(); // "/foobar"
- echo $uri->getRawQuery(); // "%61bc=%61bc"
- echo $uri->getQuery(); // "abc=abc"
复制代码 规范化示例:- use Uri\Rfc3986\Uri;
- $uri = new Uri("HTTPS://EXAMPLE.COM/foo/../bar/");
- echo $uri->getRawScheme(); // "HTTPS"
- echo $uri->getScheme(); // "https"
- echo $uri->getRawHost(); // "EXAMPLE.COM"
- echo $uri->getHost(); // "example.com"
- echo $uri->getRawPath(); // "/foo/../bar/"
- echo $uri->getPath(); // "/bar/"
复制代码 WHATWG 在修改时自动编码某些字符
如果你设置的 path 包含该组件必须百分号编码的字符,WHATWG 会自动编码:- use Uri\WhatWg\Url;
- $url = new Url("https://example.com");
- $url = $url->withPath("/?#:");
- echo $url->getPath(); // "/%3F%23:"
复制代码 这能防止意外构建出损坏的 URL。
"重复编码"陷阱(以及新 API 如何帮忙)
重复编码通常这样发生:
- 你用 rawurlencode() 编码一个值,因为"它要放进 URL"
- 你手动把它加到 query string
- 后来某处又编码了一次(框架、代理、客户端)
使用 URI 对象,一个好做法是:
- 数据保持为普通字符串(未编码)
- 用 http_build_query() 构建 query(或你偏好的编码器)
- 把结果传给 withQuery()
示例:- use Uri\Rfc3986\Uri;
- $base = new Uri("https://example.com/search");
- $params = [
- 'q' => 'php 8.5 uri',
- 'tag' => 'url/encoding',
- ];
- // RFC 3986 风格编码(空格用 %20 而非 +)
- $query = str_replace('+', '%20', http_build_query($params));
- $uri = $base->withQuery($query);
- echo $uri->toString();
复制代码 http_build_query() 不一定适合所有场景,但好处是编码规则集中在一处,而非散落在各种字符串拼接里。
如果确实需要对 path 段进行 RFC 3986 原始编码,PHP 的 rawurlencode() 遵循 RFC 3986 规则。
验证用户输入的 URL:实用的最低规则
如果输入来自用户(或不可信来源),验证应该有明确的立场。
对于你打算 fetch 或跳转的 URL,一个务实的安全导向模式:
- 必须解析成功(硬错误直接拒绝)
- 只允许 http / https
- URL 中不能有用户名/密码
- 域名白名单(用 ASCII 形式比较)
- 可选:限制端口
- 可选:要求绝对 URL(或相对 URL 基于已知 base 解析)
严格验证 helper(WHATWG 版本)
WHATWG 适合处理"浏览器风格"URL 和 IDNA。- use Uri\WhatWg\Url;
- use Uri\WhatWg\InvalidUrlException;
- function validateExternalUrl(string $input, array $allowedHosts): Url
- {
- try {
- $softErrors = [];
- $url = new Url($input, null, $softErrors);
- } catch (InvalidUrlException $e) {
- throw new InvalidArgumentException("Invalid URL.");
- }
- // Scheme 白名单
- $scheme = $url->getScheme();
- if (!in_array($scheme, ['http', 'https'], true)) {
- throw new InvalidArgumentException("Unsupported scheme.");
- }
- // 禁止凭证
- if ($url->getUsername() !== null || $url->getPassword() !== null) {
- throw new InvalidArgumentException("Credentials in URL are not allowed.");
- }
- // Host 白名单(ASCII 比较)
- $host = $url->getAsciiHost();
- if ($host === null || !in_array(strtolower($host), $allowedHosts, true)) {
- throw new InvalidArgumentException("Host not allowed.");
- }
- // 可选:端口限制
- $port = $url->getPort();
- if ($port !== null && !in_array($port, [80, 443], true)) {
- throw new InvalidArgumentException("Port not allowed.");
- }
- return $url;
- }
复制代码 几点说明:
- Url 即使解析成功也可能返回软错误。RFC 解释了"软 vs 硬"模型,并给出了解析继续但报告验证错误的例子。
- WHATWG 同时暴露 getAsciiHost() 和 getUnicodeHost();比较通常用 ASCII,展示用 Unicode。
RFC 3986 验证:严格 URI 解析 + 规范化选项
如果你在验证通用 URI(不只是 HTTP URL),或者你关心 raw vs 规范化解码的表示,Uri\Rfc3986\Uri 是个好选择。- use Uri\Rfc3986\Uri;
- use Uri\InvalidUriException;
- function validateHttpUri(string $input, array $allowedHosts): Uri
- {
- try {
- $uri = new Uri($input);
- } catch (InvalidUriException $e) {
- throw new InvalidArgumentException("Invalid URI.");
- }
- $scheme = $uri->getScheme();
- if (!in_array($scheme, ['http', 'https'], true)) {
- throw new InvalidArgumentException("Unsupported scheme.");
- }
- // Userinfo 在大多数 Web 应用中是个坑
- if ($uri->getUserInfo() !== null) {
- throw new InvalidArgumentException("Userinfo is not allowed.");
- }
- $host = $uri->getHost();
- if ($host === null || !in_array(strtolower($host), $allowedHosts, true)) {
- throw new InvalidArgumentException("Host not allowed.");
- }
- return $uri;
- }
复制代码 实际用例:签名链接、安全重定向、规范 URL
用例:签名链接而不破坏编码
签名 URL(HMAC token、临时访问链接、CDN 认证)对表示形式极其敏感。一个小的"规范化变更"就能使签名失效。
这也是 RFC 明确指出"API 客户端或签名方"通常偏好原始表示的原因。
一个简单的签名 URL 方法:
- 构建 URL
- 对稳定的字符串表示计算签名
- 把签名作为 query 参数加进去
示例,使用 RFC 3986 和 toRawString() 签名:- use Uri\Rfc3986\Uri;
- function signUri(Uri $uri, string $secret): Uri
- {
- // 当你想避免意外转换时,用 raw string
- $baseString = $uri->toRawString();
- $sig = hash_hmac('sha256', $baseString, $secret);
- $query = $uri->getRawQuery();
- $query = $query ? $query . '&' : '';
- $query .= 'sig=' . rawurlencode($sig);
- return $uri->withQuery($query);
- }
- $uri = new Uri("https://download.example.com/file/%2Fsafe?expires=1736035200");
- $signed = signUri($uri, "super-secret-key");
- echo $signed->toRawString();
复制代码 两个要点:
- 你是故意选择 raw 的,因为签名关心的是"线上实际发送的是什么"。
- query 构建仍然要小心,避免编码错误。
如果你的签名算法需要规范形式(有些确实需要),可以故意对 toString() 签名——但要知道自己在做什么,而不是意外这么做。
用例:安全重定向(避免开放重定向 + 解析混淆)
安全重定向问题通常长这样:
- 你有 /login?next=
- 登录后跳转到 next
- 攻击者尝试 next=https://evil.com 或各种变体
一个健壮的模式是:
- 把用户输入解析为相对 URL 或绝对 URL
- 相对 URL 基于你自己的已知 base 解析
- 验证 host/scheme 是你的(或在允许列表中)
使用 WHATWG:- use Uri\WhatWg\Url;
- function safeRedirectTarget(string $next, string $appBase): string
- {
- $base = new Url($appBase);
- // 安全解析相对引用
- $errors = [];
- $resolved = Url::parse($next, $base, $errors);
- if ($resolved === null) {
- return $base->toAsciiString(); // fallback
- }
- // 只允许同 host
- if (strtolower($resolved->getAsciiHost() ?? '') !== strtolower($base->getAsciiHost() ?? '')) {
- return $base->toAsciiString();
- }
- // 只允许 http/https
- if (!in_array($resolved->getScheme(), ['http', 'https'], true)) {
- return $base->toAsciiString();
- }
- return $resolved->toAsciiString();
- }
- echo safeRedirectTarget("/dashboard", "https://app.example.com");
复制代码 这里用到了 base URL 解析和引用解析功能。
用例:规范 URL(SEO 和缓存的一致表示)
规范化通常意味着:
- scheme/host 小写
- 移除 path 中的点号段
- 移除默认端口
- query 保持一致(有时排序)
- 决定如何处理末尾斜杠
使用 Uri\Rfc3986\Uri,你已经可以通过 get*() vs getRaw*()(以及 toString() vs toRawString())清晰地获得规范化行为。
一个简单的规范化示例:- use Uri\Rfc3986\Uri;
- function canonicalizeForCache(Uri $uri): Uri
- {
- // 从规范化解码的部分开始
- $uri = $uri
- ->withScheme($uri->getScheme())
- ->withHost($uri->getHost())
- ->withPath($uri->getPath());
- // 为缓存 key 排序 query 参数(这是一种策略选择)
- $query = $uri->getQuery();
- if ($query) {
- parse_str($query, $params);
- ksort($params);
- $sorted = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
- $uri = $uri->withQuery($sorted);
- }
- return $uri;
- }
- $u = new Uri("HTTPS://EXAMPLE.COM/foo/../bar/?b=2&a=1");
- echo canonicalizeForCache($u)->toString();
复制代码 这个函数不试图覆盖所有规范化策略(那是应用层面的事),但结构化 API 的好处是:规则可以显式构建、可以测试。
从 parse_url() + 字符串拼接迁移(不痛苦)
你不需要一次性重写所有代码。最简单的迁移路径是:
1. 找出 URL 处理对安全敏感或容易出 bug 的地方:
- 重定向
- webhook 验证
- 签名 URL
- 域名白名单
- 路由/缓存 key
2. 先替换这些。
3. 纯展示用途的简单解析,等到有必要时再处理。
迁移前:数组解析 + 手动重建
- $parts = parse_url($input);
- $host = $parts['host'] ?? null;
- if ($host !== 'example.com') {
- throw new Exception("Bad host");
- }
- $newUrl = $parts['scheme'] . '://' . $parts['host'] . '/new-path';
复制代码 问题:
- 没处理 port、userinfo、fragment、相对 URL
- 没有规范化
- 没有规范化选择
- 容易构建出无效输出
迁移后:使用 Url/Uri,改动局部化
- use Uri\WhatWg\Url;
- $url = new Url($input);
- if (strtolower($url->getAsciiHost() ?? '') !== 'example.com') {
- throw new InvalidArgumentException("Bad host");
- }
- $new = $url->withPath('/new-path');
- echo $new->toAsciiString();
复制代码 代码更短,意图更明确,细节上更难出错。
RFC 3986 和 WHATWG 怎么选(经验法则)
使用 Uri\WhatWg\Url 当:
- 你在处理来自浏览器/用户的 HTTP(S) URL
- 你需要 IDNA/Unicode 域名处理(getUnicodeHost() 用于展示,getAsciiHost() 用于比较)
- 你想要 WHATWG 解析行为和错误报告(软错误)
使用 Uri\Rfc3986\Uri 当:
- 你在处理"Web URL"之外的通用 URI
- 你需要 raw vs 规范化解码的表示
- 你在做签名或协议层面的工作,表示形式很重要
RFC 明确区分了这两种方法,包括 Unicode/IDNA 支持的差异。
附加内容:正确比较 URL(equals() 以及为什么没有 __toString())
一个值得留意的设计:内置 URI 类故意不实现 __toString(),因为松散比较(==)容易出错。
取而代之,你用 equals() 比较:- use Uri\Rfc3986\Uri;
- use Uri\UriComparisonMode;
- $u1 = new Uri("https://example.COM#foo");
- $u2 = new Uri("https://EXAMPLE.COM");
- var_dump($u1->equals($u2)); // true
- var_dump($u1->equals($u2, UriComparisonMode::IncludeFragment)); // false
复制代码 WHATWG 类似:- use Uri\WhatWg\Url;
- use Uri\UriComparisonMode;
- $a = new Url("https:////example.COM/");
- $b = new Url("https://EXAMPLE.COM");
- var_dump($a->equals($b)); // true
复制代码 结论
PHP 8.5 的 URI 扩展看起来是个小功能,用一段时间后才会发现它挡掉了多少细微的 URL bug。
核心价值在于控制:
- 你可以选择 RFC 3986 vs WHATWG 语义。
- 你可以决定要 raw 还是规范化解码的表示。
- 你可以不可变且安全地修改组件。
- 你可以用更难意外削弱的方式验证和比较 URL。
如果你曾经发布过一个"快速 URL 修复",后来在安全报告或生产事故中看到它,这个扩展值得尽早采用——从重定向和签名 URL 开始。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |