PHP 8.5 全新 URI 扩展实战:写一个靠谱的 OAuth 回调白名单校验器

2026-10-13 0 285

先说一件旧事。三年前我做 OAuth 接入,要把用户传过来的回调地址跟白名单比对。白名单里写了一串 https://app.example.com/callback,觉得万无一失。上线之后有人提交了 https://app.example.com/callback?x=1,被拒绝;有人提交 https://app.example.com:443/callback,也被拒绝;还有人用 HTTPS://app.example.com/callback,继续被拒。

问题在于,字符串比较永远解决不了 URL 的等价性问题。:443 是 https 的默认端口、大小写在部分组件里不敏感、多余的空格和百分号编码形式各异。我们只能一个个补规则,到后面那段校验代码变成了一堆 str_replace 拼起来的地雷阵。

这些事情本不该手写。PHP 8.5 终于把 URI 变成了语言层面的东西,两个新的类可以覆盖几乎所有 URL 处理需求。这篇文章拿当年那个校验器做例子,把它重写一遍。

parse_url 到底哪里不够

先摆一下旧工具的问题,这样能更清楚地看到新的东西解决了什么。

parse_url 是函数式的,返回一个数组,失败时返回 false 或者产出警告。它不区分”没给这个部分”和”给了个空值”,字段有时有有时没有。写起来永远是 $parts['host'] ?? '' 这种判断。

它对 URL 的合法性检查非常宽松。parse_url('https://') 会返回 ['scheme' => 'https'],没有 host 也没有报错。这在做安全校验的时候是灾难性的——你以为输入合法,其实是个残次品。

它没有规范化能力。同一份地址,写 :443 和省略端口是两码事,它不会帮你归一。它也不能做相对路径解析,把你从浏览器里学到的”./ 是当前目录”这套逻辑用上去。

顺便说一句,PHP 8.5 里 parse_url 并没有被废弃——它依然能用,只是在新代码里我会优先用 URI 扩展。老代码不用急着改。

两个类,到底该用哪个

PHP 8.5 引入了两个独立的 URI 实现,放在 Uri 命名空间下。

UriRfc3986Uri 严格遵循 RFC 3986。这个是互联网的教科书标准,对什么字符可以出现在哪一段规定得非常细。它的校验体系严格,遇到不合规的输入会直接判错。适合做协议交互的场景——比如你正在写 OAuth 服务端,需要严格按规范校验客户端提交的回调地址。

UriWhatWgUrl 遵循 WHATWG URL 标准,也就是浏览器用的那套。它比 RFC 3986 宽容得多,会自动修复一些常见的不规范写法。比如 HTTP://Example.COM:80/ 会被拆成 scheme 是 http、host 是 example.com、port 是 null(因为 80 是默认端口,被自动去掉了)。适合做用户输入处理、爬虫 URL 归一化这类场景。

选哪个?一个简单的判断方法:如果这份 URL 要和浏览器打交道,用 WhatWg;如果它要在两个服务之间按规范传递,用 Rfc3986。

我这个校验器的场景是用户从浏览器把回调地址填进表单,然后服务端要跟白名单比。也就是说来源是浏览器,目标也是浏览器,那用 WhatWg 更贴切。

一起看一遍两个类的解析行为

先上代码:

<?php

declare(strict_types=1);

use UriRfc3986Uri;
use UriWhatWgUrl;

$input = 'HTTPS://App.Example.COM:443/callback?a=1#done';

$rfc = Uri::parse($input);
$wg  = Url::parse($input);

echo "RFC 3986:n";
var_dump($rfc->getScheme());
var_dump($rfc->getHost());
var_dump($rfc->getPort());
var_dump($rfc->getPath());
var_dump($rfc->getQuery());
var_dump($rfc->getFragment());

echo "WHATWG:n";
var_dump($wg->getScheme());
var_dump($wg->getHost());
var_dump($wg->getPort());
var_dump($wg->getPath());
var_dump($wg->getQuery());
var_dump($wg->getFragment());

看结果差在哪。

RFC 3986 那边,getScheme() 返回的是原样的大写 HTTPS,getHost() 也是原样保留的大小写混合。这是 RFC 的要求——URI 的各个组件在执行规范化之前,大小写都是”原样保留”的。getPort() 返回 443,因为它不知道 443 是 https 的默认端口——默认端口这件事是 HTTP 层的事,不是 URI 层的事。

WhatWg 那边就完全不一样了。scheme 回来的是小写的 https,host 是小写的 app.example.com,port 是 null——因为 443 被识别出来是 https 的默认端口,直接省略。

这就是为什么说做用户输入处理的时候不能直接用 RFC 3986——它返回的东西虽然”正确”,但跟用户在浏览器里看到的会对不上。

开始写校验器

回到那个场景。

<?php

declare(strict_types=1);

use UriWhatWgUrl;

final class CallbackValidator
{
    /** @var string[] */
    private array $allowedHosts;
    private array $allowedSchemes;

    public function __construct(array $allowedHosts, array $allowedSchemes = ['https'])
    {
        $this->allowedHosts = array_map('strtolower', $allowedHosts);
        $this->allowedSchemes = $allowedSchemes;
    }

    public function isAllowed(string $raw): bool
    {
        $url = Url::parse($raw);
        if ($url === null) {
            return false;
        }

        if (!in_array($url->getScheme(), $this->allowedSchemes, true)) {
            return false;
        }

        $host = $url->getHost();
        if ($host === null) {
            return false;
        }

        return in_array(strtolower($host), $this->allowedHosts, true);
    }
}

用法:

$validator = new CallbackValidator([
    'app.example.com',
    'auth.example.com',
]);

var_dump($validator->isAllowed('https://app.example.com/callback'));   // true
var_dump($validator->isAllowed('HTTPS://App.Example.COM/callback'));   // true
var_dump($validator->isAllowed('https://app.example.com:443/cb'));    // true
var_dump($validator->isAllowed('http://app.example.com/callback'));   // false
var_dump($validator->isAllowed('https://evil.com/app.example.com/')); // false

最后那一行特意写出来。如果白名单匹配是靠简单的 str_contains 或者正则 .*app.example.com.* 做的,evil.com 会把 app.example.com 塞在路径里骗过检查。用 getHost() 拿真实 host 来比,就不会出这种岔子。这种事自己写正则永远会漏情况,用解析器就没有这个问题。

让它支持子域和白名单路径

实际业务里往往还有两个需求:允许所有 *.example.com 的子域,以及只允许某个路径下的回调。

final class CallbackValidator
{
    private array $allowedHosts;
    private array $allowedSchemes;
    private bool $allowSubdomains;
    private ?string $requiredPathPrefix;

    public function __construct(
        array $allowedHosts,
        array $allowedSchemes = ['https'],
        bool $allowSubdomains = false,
        ?string $requiredPathPrefix = null,
    ) {
        $this->allowedHosts = array_map('strtolower', $allowedHosts);
        $this->allowedSchemes = $allowedSchemes;
        $this->allowSubdomains = $allowSubdomains;
        $this->requiredPathPrefix = $requiredPathPrefix;
    }

    public function isAllowed(string $raw): bool
    {
        $url = Url::parse($raw);
        if ($url === null) {
            return false;
        }

        if (!in_array($url->getScheme(), $this->allowedSchemes, true)) {
            return false;
        }

        $host = $url->getHost();
        if ($host === null) {
            return false;
        }
        $host = strtolower($host);

        if (!$this->hostMatches($host)) {
            return false;
        }

        if ($this->requiredPathPrefix !== null) {
            $path = $url->getPath() ?? '/';
            if (!str_starts_with($path, $this->requiredPathPrefix)) {
                return false;
            }
        }

        return true;
    }

    private function hostMatches(string $host): bool
    {
        if (in_array($host, $this->allowedHosts, true)) {
            return true;
        }

        if (!$this->allowSubdomains) {
            return false;
        }

        foreach ($this->allowedHosts as $allowed) {
            if (str_ends_with($host, '.' . $allowed)) {
                return true;
            }
        }

        return false;
    }
}

子域匹配那段的 '.' . $allowed 前面那个点不能省。省掉的话 evilexample.com 会被判定为 example.com 的子域——它的字符串结尾确实是 example.com,但不带点的匹配在这里就是错的。

路径前缀匹配也一样有个边界问题。requiredPathPrefix = '/callback',写 str_starts_with($path, '/callback'),/callback-evil 也会通过。要更严格一点,应该比较下一个字符是不是 /:

if ($path !== $this->requiredPathPrefix &&
    !str_starts_with($path, $this->requiredPathPrefix . '/')) {
    return false;
}

看着啰嗦,但这正是”用解析器也好、做字符串比较也好”,都是细节堆出来的东西。没什么优雅的办法,只能一个一个想清楚。

相对路径解析

校验之外,另一个常见需求是”根据回调地址拼一个跳转目标”。比如 OAuth 授权成功后要回跳到 https://app.example.com/callback?code=xxx&state=yyy。这个拼接用字符串加号很容易把已经存在的查询串覆盖掉,或者留下双重的 ?。

URL 里拼接的正规做法是用”相对引用解析”。Rfc3986Uri 提供了这个方法,WhatWgUrl 应该也有对应的实现(规范里叫 resolve):

use UriRfc3986Uri;

$base = Uri::parse('https://app.example.com/auth/callback?v=1');

$r1 = $base->resolve('login');
// https://app.example.com/auth/login

$r2 = $base->resolve('/status');
// https://app.example.com/status

$r3 = $base->resolve('../logout');
// https://app.example.com/logout

$r4 = $base->resolve('?next=home');
// https://app.example.com/auth/callback?next=home

这套规则和你熟悉的浏览器地址栏行为完全一致。login 是相对当前路径,/status 从根目录开始,../ 回退一级,?next=home 只替换查询串。以前要在 PHP 里实现这个,得上 league/uri 之类的库。现在语言自带了。

拿这个实现”保留原有查询参数、追加新的”就非常干净:

function appendQuery(Uri $uri, string $key, string $value): Uri
{
    $current = $uri->getQuery();
    $extra = rawurlencode($key) . '=' . rawurlencode($value);
    $merged = $current === null || $current === ''
        ? $extra
        : $current . '&' . $extra;

    return $uri->withQuery($merged);
}

withQuery 是 URI 对象上的”不可变修改”方法。它不改变原对象,返回一个新的实例。这类 withXxx 系列的命名和 PSR-7 里的请求对象一致,用过 Guzzle 或者 Nyholm 的人会很熟悉。

归一化:什么是”同样的 URL”

从 URL 本身的角度看,”同样的 URL”有两种意思。

第一种是语法上完全一致。https://a.com/x 和 https://a.com/x 就是同一个。这没什么好说的。

第二种是语义上等价。https://a.com 和 https://a.com/ 实际上访问的是同一个资源,但字面量不一样。

URI 对象上的 equals() 方法处理的是第二种。它会先对两边做一次语法归一化,再比较。

use UriRfc3986Uri;

$u1 = Uri::parse('https://Example.COM/path');
$u2 = Uri::parse('https://example.com/path');

var_dump($u1->equals($u2));  // true

$u3 = Uri::parse('https://example.com/path?b=2&a=1');
$u4 = Uri::parse('https://example.com/path?a=1&b=2');

var_dump($u3->equals($u4));  // false

最后那个返回 false,可能会让人意外。原因是 URI 规范并没有规定查询参数的顺序可以改变——?b=2&a=1 和 ?a=1&b=2 在字符串上不同,对于服务端来说也可能是两个不同的请求(比如某个博客接口按参数顺序区分)。

如果你做的事确实不关心查询参数的顺序,得自己在比较前做一层归一化:

function sortQuery(Uri $uri): Uri
{
    $query = $uri->getQuery();
    if ($query === null || $query === '') {
        return $uri;
    }

    $parts = explode('&', $query);
    sort($parts);

    return $uri->withQuery(implode('&', $parts));
}

粗暴但管用。sort() 是按字符串排的,参数值里如果还有 & 的编码形式不会被打乱——因为大家都是在同一次 explode 之后处理的。

别忘了错误处理

URL 层的最底层是用户输入,一定会有奇奇怪怪的字符串进来。Url::parse 和 Uri::parse 的签名是这个样子:

public static function parse(string $uri, &$errors = null): ?static

解析失败时返回 null,同时把错误信息写进第二个引用参数。

$errors = [];
$url = Url::parse($rawInput, $errors);

if ($url === null) {
    // $errors 是一个数组,里面是详细原因
    logger()->warning('URL 解析失败', ['input' => $rawInput, 'errors' => $errors]);
    return null;
}

不要忽略这些错误。在排查”为什么这个回调地址一直被拒”这类问题时,错误信息往往比什么都管用。有时候是用户真的输错了,有时候是自己的解析逻辑有问题——两者从表现上看起来一样,但错误信息能区分开。

有一点比较容易忘:Rfc3986Uri 和 WhatWgUrl 的宽容程度差得很远。同一个输入,很可能 RFC 那个判失败而 WhatWg 判成功。所以不要拿 Rfc3986 的结果去判断”这个 URL 是不是合法”——它回答的是”这个 URL 是不是完全合规”。

一点收尾的想法

我当年那个用正则加拼接写出来的校验器,前后维护了两年多,补了十几个 corner case。有了 URI 扩展之后,我把它整个换掉了,最后算下来是三十多行。

但真正省的不是行数,是”不用再猜测”。以前每加一条规则都是靠猜——用户还可能输什么变体?现在这些规则由标准化组织替我们维护,getHost() 拿到的就是”这个 URL 的主机”,不需要每次都在日志里翻一遍,看到底是 Example.COM 还是 example.com。

如果你手上还有一堆 parse_url 加手工字符串操作拼起来的 URL 处理代码,可以挑一个坑最多的、出错最多的模块,先试着改成 URI 扩展。跑通之后再推广到其它地方,改动量其实不大。

PHP 8.5 全新 URI 扩展实战:写一个靠谱的 OAuth 回调白名单校验器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.5 全新 URI 扩展实战:写一个靠谱的 OAuth 回调白名单校验器 https://www.taomawang.com/server/php/2931.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务