一个URL能被解析出多少种不同的结果?答案可能会让你感到意外。

2022年,安全研究人员Joshua Reynolds等人发表了一篇题为《Equivocal URLs: Understanding the Fragmented Space of URL Parser Implementations》的学术论文。他们对15个主流URL解析器进行了系统测试,发现了一个令人震惊的事实:针对同一个URL,不同解析器给出的主机名竟然可以完全不同。更严重的是,某些情况下这种差异会被攻击者利用,绕过Google Safe Browsing和VirusTotal等安全检测系统。

这并非危言耸听。在真实世界中,URL解析差异已经导致了无数SSRF(服务器端请求伪造)、开放重定向、OAuth劫持等严重漏洞。问题的根源在于:URL解析从未被真正标准化过

从RFC到WHATWG:三十五年的标准分裂

URL的故事要从1994年说起。那一年,Tim Berners-Lee在RFC 1738中首次定义了Uniform Resource Locator的概念。当时的Web还处于萌芽阶段,URL的设计目标是解决"如何定位网络资源"这个问题。

RFC 1738采用了一种相对简单的语法描述:

<scheme>:<scheme-specific-part>

对于HTTP URL,格式被定义为:

http://<host>:<port>/<path>?<searchpart>

看起来足够清晰?问题很快就出现了。1998年,RFC 2396试图将URL扩展为更通用的URI(Uniform Resource Identifier)概念,加入了更复杂的语法规则。2005年,RFC 3986进一步细化了URI的语法,引入了百分号编码、国际化域名等概念。

但浏览器厂商并不买账。

浏览器每天要处理大量"不合规"的URL:用户输入可能缺少scheme、包含非ASCII字符、或者带有各种边缘情况。严格遵守RFC 3986意味着大量用户输入会被拒绝,这在商业上是不可接受的。

于是,WHATWG(Web Hypertext Application Technology Working Group)决定另起炉灶。他们创建了"URL Living Standard"——一个持续更新的标准,其明确目标是"对齐RFC 3986和RFC 3987与当代实现,并在过程中使其过时"。

这就是分裂的开始。今天,当一个开发者说"URL解析"时,他可能指的是:

  • RFC 3986的严格语法解析
  • RFC 3987的国际化扩展
  • WHATWG的浏览器兼容解析
  • 某个编程语言自己的实现

而这四种方式给出的结果,可能完全不同。

URL的结构:比你想的要复杂

让我们先厘清URL到底由哪些部分组成。根据WHATWG标准,一个完整的HTTP URL包含以下组件:

https://user:password@host:8080/path/to/resource?query=value#fragment
  │      │    │        │    │    │                │            │
scheme  │    │        │    │    │                │            │
      userinfo   host  port  path            query        fragment

每个组件都有严格的字符限制和编码规则:

Scheme:以字母开头,可包含字母、数字、加号、减号和点号。必须以冒号结尾。

Authority部分(包含userinfo、host、port):

  • userinfo:格式为username:password,可选,以@结尾
  • host:域名或IP地址,IPv6地址必须用方括号包围
  • port:十进制数字,可选

Path:由斜杠分隔的路径段,每个段都可以包含百分号编码字符

Query:以问号开头,可包含任意字符(但某些字符需要编码)

Fragment:以井号开头,用于标识资源的特定部分

看起来很清晰?当我们开始解析时,复杂性就显现出来了。

解析算法:一个八十步的状态机

WHATWG的URL解析算法是一个包含约80个状态的状态机。以下是核心流程的简化版本:

输入字符串
    ↓
┌─────────────────────────────────────┐
│ 1. 去除前后空白字符和C0控制字符      │
│ 2. 移除制表符和换行符               │
│ 3. 检查并处理特殊scheme             │
└─────────────────────────────────────┘
    ↓
确定base URL(如果没有scheme)
    ↓
┌─────────────────────────────────────┐
│ 状态机解析:                        │
│ - scheme start                     │
│ - scheme                           │
│ - no scheme                        │
│ - special authority slash          │
│ - special authority ignore slashes │
│ - authority                        │
│ - host                             │
│ - port                             │
│ - path                             │
│ - query                            │
│ - fragment                         │
└─────────────────────────────────────┘
    ↓
验证各组件并规范化
    ↓
输出URL对象

这个算法的关键特征是容错性:当遇到不符合规范的输入时,解析器会尝试"修复"而不是直接拒绝。例如:

  • 缺少scheme?尝试使用base URL
  • 反斜杠?在某些情况下自动转换为正斜杠
  • 非ASCII字符?自动进行Punycode编码
  • 缺少端口?使用scheme的默认端口

这种容错设计让浏览器能够处理用户的各种输入,但也埋下了安全隐患。

七大陷阱:URL解析的危险地带

Reynolds等人在研究中系统性地总结了URL解析中的七个关键陷阱,每一个都可能导致严重的安全漏洞。

陷阱一:空字节处理差异

C语言以空字节(0x00)作为字符串结束符,而Python、Java等语言则使用长度属性来管理字符串。这种底层差异在URL解析中产生了意想不到的后果。

考虑这个URL:

https://n.pr[0x00]@e.gg

不同解析器的处理结果:

解析器 结果
libcurl, wget, Apache host = “n.pr”(空字节截断)
Python urllib, Perl host = “e.gg”(空字节作为userinfo的一部分)
Go, Java, Ruby 拒绝(检测到非法字符)

当安全验证使用一种解析器,而实际请求使用另一种时,攻击者就可以绕过检查。

陷阱二:反斜杠修正

浏览器为了兼容Windows文件路径,会将URL中的反斜杠转换为正斜杠。这个"功能"被多次用于攻击:

https://n.pr\@e.gg

浏览器解析结果:

  • Chrome/Firefox:host = “e.gg”(反斜杠变为分隔符)
  • Python urllib:host = “[email protected]”(反斜杠作为userinfo的一部分)

这个陷阱已经被多次用于缓存投毒攻击。James Kettle在2018年的研究中展示了如何利用这个差异,让服务器投毒自己的缓存。

陷阱三:过度百分号解码

百分号编码(如%2F代表/)是URL中包含特殊字符的标准方法。问题在于:解码的时机和位置。

https://n.pr%2ee.gg

这个URL的%2e代表点号。浏览器会自动解码为:

https://n.pr.e.gg

但如果安全检查在解码前进行,它看到的是n.pr,而实际请求会发送到n.pr.e.gg

更严重的是JSON中的歧义。当URL通过JSON传递时:

{"url": "https://trusted.com%[email protected]"}

服务端无法确定%2F是:

  1. 编码的正斜杠(属于分隔符)
  2. userinfo中的编码字符

Google Safe Browsing API就因此产生过误报:它将%2F解读为分隔符,认为URL指向trusted.com,而实际浏览器会请求evil.com

陷阱四:IPv6地址语法

IPv6地址在URL中必须用方括号包围:

https://[2001:db8::1]:8080/path

但解析器对括号的处理存在差异:

https://n.pr][e.gg

解析结果:

解析器 host
Node.js Legacy n.pr
Python urllib e.gg
大多数其他解析器 拒绝

这种差异可以被用于绕过基于IP地址的访问控制。

陷阱五:自动Punycode转换

国际化域名(IDN)使用Punycode编码将非ASCII域名转换为ASCII格式。问题在于:不同解析器的转换规则不同。

https://n.pr[0xC4B0]@e.gg

字符U+0130(İ,带点的拉丁大写I)在不同解析器中会被转换为不同的Punycode:

  • 一种结果:n.xn--prie-swc.gg
  • 另一种结果:n.xn--pre-tfa3h.gg

这导致同一个URL被解析为完全不同的域名。Orange Tsai在Black Hat 2017的演讲中展示了如何利用这个差异进行SSRF攻击。

陷阱六:低ASCII字节

低于可打印ASCII范围的字节(0x00-0x1F)在URL中是非法的,但解析器处理方式各异:

https://n.pr[0x0A]e.gg

0x0A是换行符。解析结果:

解析器 处理
Go, Java, Ruby 拒绝
大多数解析器 忽略换行或将其视为userinfo的一部分
Perl 忽略换行,host = “n.pre.gg”

陷阱七:非法额外分隔符

URL规范明确定义了各部分的分隔符:://分隔scheme和authority,@分隔userinfo和host,#开始fragment。但解析器对重复或错位分隔符的处理并不一致:

https://n.pr#@e.gg

这里的#应该开始fragment,@e.gg是fragment的一部分。但某些解析器会错误地认为@重新开始了authority部分。

https://trusted.com#@evil.com/malware

如果安全检查器忽略fragment,它会认为URL指向trusted.com。但如果目标服务器处理了整个URL…

安全影响:从理论到实战

这些解析差异并非纸上谈兵,它们已经在真实世界中造成了严重后果。

SSRF:绕过内网防护

SSRF攻击的核心是让服务器请求攻击者指定的目标。当服务器使用白名单验证URL时:

# 验证逻辑
if url.startswith('https://trusted.com'):
    return fetch(url)

攻击者可以构造:

https://[email protected]

如果验证使用简单的字符串匹配,而实际请求由curl发出,服务器会请求evil.com

2019年,Jann Horn发现了一个影响Google Cloud的严重漏洞:攻击者可以通过精心构造的URL绕过metadata服务的访问控制。漏洞的根本原因正是URL解析差异——安全检查和实际请求使用了不同的解析逻辑。

OAuth劫持:重定向陷阱

OAuth流程依赖重定向URL进行身份验证。如果认证服务和客户端使用不同的URL解析器:

https://client-app.com/[email protected]

认证服务可能认为这是合法的回调地址(因为domain是client-app.com),但浏览器会重定向到evil.com。

Wang等人在Black Hat Asia 2019的演讲中详细展示了这种攻击如何影响多个OAuth实现。

缓存投毒:投毒自己的服务器

2018年,James Kettle发现了一个影响多个CDN和Web框架的漏洞。攻击原理如下:

GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: evil.com\@vulnerable.com

某些缓存系统在计算缓存键时,会错误地处理这个URL,导致恶意响应被缓存。

钓鱼:欺骗安全检测器

Reynolds等人在研究中展示了如何欺骗Google Safe Browsing和VirusTotal:

http://letsencrypt.org%[email protected]/testing/malware/*

当这个URL被提交给安全检测API时,API将%2F解码为分隔符,认为URL指向letsencrypt.org。但当浏览器访问时,它会请求恶意域名。

更讽刺的是,研究人员在2021年10月就向Google报告了这个问题,但截至论文发表,问题仍然存在。

解析器生态:碎片化的现实

让我们看看主流语言的URL解析器在面对模糊测试时的表现。Reynolds等人的研究测试了15个解析器对98,445个测试URL的处理:

与RFC 3986的符合度

解析器 符合度
Ruby URI 99.95%
PHP parse_url 97.79%
Python urllib 90.67%
WHATWG NodeJS 59.74%
Go net/url 26.61%
Java.net.URI 28.74%
NodeJS Legacy 4.94%

与WHATWG的符合度

解析器 符合度
NodeJS WHATWG 100.00%
Python urllib 66.95%
PHP parse_url 58.09%
Ruby URI 59.78%
RFC 3986 59.74%

值得注意的是,即使是WHATWG自己的参考实现与RFC 3986也只有59.74%的符合度。这说明两个标准本身存在根本性差异。

不同语言的标准库解析器各有特点:

Pythonurllib.parse模块提供了多个函数:

  • urlparse():将URL解析为6元组(scheme, netloc, path, params, query, fragment)
  • urlsplit():类似但不分割params
  • urlunparse()/urlunsplit():反向操作

Python的解析器相对严格,但在处理非法字节时存在历史遗留问题。

JavaScript/Node.js:历史上存在两套API:

  • url.parse()(Legacy):Node.js传统的解析方式,性能更好但不完全符合WHATWG
  • new URL()(WHATWG):符合WHATWG标准的现代API

Node.js团队在2022年讨论过是否弃用legacy API,但最终保留了它——因为legacy API在某些边缘场景下更快,且支持一些WHATWG不支持的使用模式。

Gonet/url包提供url.Parse()函数。Go的解析器对IPv6地址处理有特殊规则,且在处理非法字节时与其他解析器行为不同。

Javajava.net.URIjava.net.URL是两个不同的类。URI更严格,URL会尝试解析host。Java的解析器在研究中对测试用例的拒绝率最高——这既可以是安全优势,也可能是兼容性问题。

PHPparse_url()函数将URL解析为关联数组。PHP官方文档中有一个警告:

注意:此函数可能无法对相对URL或无效URL给出正确结果,其结果可能与HTTP客户端的常见行为不一致。

这个警告本身就是问题的证明。

浏览器行为:另一重复杂性

浏览器不仅要解析URL,还要显示给用户。这增加了一个维度:用户看到的是URL的哪个部分?

Chrome的安全文档建议,在显示URL时应该:

  1. 显示完整的host(包括可能欺骗性的部分)
  2. 将scheme和subdomain用不同颜色标记
  3. 检测并警告可能的同形字攻击

但实际实现中,浏览器会在地址栏隐藏许多信息:

  • 默认端口(80/443)被隐藏
  • userinfo部分在某些情况下被隐藏
  • 长路径会被截断

这导致用户看到的URL和实际请求的URL可能存在差异。钓鱼攻击者经常利用这一点,构造一个在地址栏看起来"正常"的URL:

https://[email protected]/login

如果浏览器只显示"paypal.com"部分,用户可能会误以为这是真正的PayPal网站。

最佳实践:如何安全地处理URL

面对复杂的URL解析生态,开发者应该采取哪些措施?

使用标准化解析器

推荐:在所有可能的地方使用相同的解析器。如果服务器和客户端都运行在JavaScript环境,使用new URL()。如果服务器是Python,考虑使用与客户端行为一致的库。

避免:混用不同语言的URL解析器处理同一个URL,或者在安全验证和实际请求之间使用不同的解析逻辑。

验证之前先规范化

在验证URL之前,先进行规范化:

from urllib.parse import urlparse, urlunparse

def normalize_url(url):
    parsed = urlparse(url)
    # 规范化scheme
    scheme = parsed.scheme.lower()
    # 规范化host
    netloc = parsed.netloc.lower()
    # 重新组装
    return urlunparse((scheme, netloc, parsed.path, 
                       parsed.params, parsed.query, parsed.fragment))

白名单验证而非黑名单

不要试图列出所有可能的恶意URL模式——攻击者总能找到新的绕过方式。相反,明确定义允许的模式:

def is_allowed_url(url, allowed_domains):
    parsed = urlparse(url)
    host = parsed.netloc.split(':')[0]  # 移除端口
    
    # 只允许精确匹配或子域名
    return any(
        host == domain or host.endswith('.' + domain)
        for domain in allowed_domains
    )

检查origin而非URL

对于跨域请求,验证origin比验证URL更可靠:

function isAllowedOrigin(url, allowedOrigins) {
    try {
        const parsed = new URL(url);
        const origin = parsed.origin; // scheme + host + port
        return allowedOrigins.includes(origin);
    } catch {
        return false;
    }
}

注意IDN和Punycode

处理国际化域名时,统一使用Punycode表示:

import idna

def normalize_host(host):
    try:
        # 如果是Unicode,编码为Punycode
        return idna.encode(host).decode('ascii')
    except idna.IDNAError:
        # 如果已经是ASCII或无效,原样返回
        return host

API演进:更安全的URL处理

JavaScript的URL API正在演进,以解决历史遗留问题。

URL.parse():不再抛出异常

new URL()的一个设计缺陷是:当URL无效时,它会抛出异常。这导致开发者需要用try-catch包裹每次调用:

// 传统方式
let url;
try {
    url = new URL(userInput);
} catch {
    // 处理无效URL
}

2024年,URL.parse()被引入标准。它返回null而非抛出异常:

// 新方式
const url = URL.parse(userInput);
if (url === null) {
    // 处理无效URL
}

这个改进由Anne van Kesteren在2018年提出,经过多年讨论终于在2024年实现。Firefox 126和Chrome 126开始支持这个API。

URL.canParse():验证而不创建

URL.canParse()允许在不创建URL对象的情况下检查有效性:

if (URL.canParse(userInput)) {
    const url = new URL(userInput);
    // 安全使用url
}

这个API在2023年底获得跨浏览器支持。

性能优化:Ada解析器

2025年的性能测试显示,Ada URL解析器成为最快的选择。Ada是一个用C++编写的URL解析器,完全符合WHATWG标准。

解析器 相对性能
Ada 7.1x(相比curl)
Node.js new URL() 基准
Python urllib 较慢
Go net/url 较慢

Ada已被集成到Node.js作为可选的解析器后端。在Vercel的规模下,使用Ada可以节省86%的CPU使用率。

未来展望:统一还有多远?

URL解析的混乱源于一个根本矛盾:严格的规范与宽松的实践之间的冲突

RFC 3986选择了严格:明确定义语法,不符合则拒绝。这符合工程师的直觉——如果输入不规范,报错是正确的行为。

浏览器选择了宽松:尽可能修复用户的输入,让Web可以工作。这符合产品逻辑——用户不会在意RFC,他们只想访问网站。

两种选择都有道理。但当它们在同一系统中相遇时,漏洞就产生了。

WHATWG试图统一这两个世界。他们的URL标准提供了详细的解析算法,让所有实现者都能得到一致的结果。但标准本身是复杂的——超过300页的算法描述,即使是经验丰富的开发者也难以完全理解。

更根本的问题是:是否应该统一?

如果所有解析器都变得严格,大量现有URL将无法使用。研究表明,约0.16%的Alexa Top 100万域名使用Punycode,约0.04%的URL包含需要客户端解析的Unicode字符。这个比例看似很小,但在Web规模下意味着数百万个请求。

如果所有解析器都变得宽松,安全边界将变得模糊。安全工具无法确定一个URL的"真实"含义——因为根本没有唯一的真实含义。

也许真正的解决方案是:明确语义边界

安全验证工具应该明确声明它使用哪种解析方式。服务端应该确保安全检查和实际请求使用相同的解析器。API设计者应该考虑URL在传输过程中的编码层次。

URL解析不会在一夜之间变得统一。但理解它的复杂性,是构建安全Web应用的第一步。


参考资料

  1. Reynolds, J., Bates, A., & Bailey, M. (2022). Equivocal URLs: Understanding the Fragmented Space of URL Parser Implementations. ESORICS 2022.

  2. WHATWG. URL Living Standard. https://url.spec.whatwg.org/

  3. Berners-Lee, T., Fielding, R., & Masinter, L. (2005). RFC 3986: Uniform Resource Identifier (URI): Generic Syntax.

  4. PortSwigger. URL Validation Bypass Cheat Sheet. https://portswigger.net/research/introducing-the-url-validation-bypass-cheat-sheet

  5. Kettle, J. (2018). Practical Web Cache Poisoning. PortSwigger Research.

  6. Tsai, O. (2017). A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages. Black Hat USA.

  7. SonarSource. (2022). Security Implications of URL Parsing Differentials.

  8. Valkhof, K. (2024). The problem with new URL(), and how URL.parse() fixes that.

  9. Nizipli, Y. (2025). State of URL parsing performance in 2025.

  10. Google Chromium. URL Display Guidelines. https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/url_display_guidelines/

  11. Snyk. (2022). URL confusion vulnerabilities in the wild: Exploring parser differentials.

  12. Horn, J. (2019). SSRF via maliciously crafted URL due to host confusion. HackerOne Report #704621.

  13. Wang, X., et al. (2019). Make Redirection Evil Again: URL Parser Issues in OAuth. Black Hat Asia.

  14. IETF. RFC 1738: Uniform Resource Locators (URL). (1994).

  15. Unicode Technical Standard #39: Unicode Security Mechanisms.