你在地址栏里输入 https://商店.example/商品/咖啡豆?规格=中包#评价,浏览器显示的是中文,复制到日志里却可能变成一长串 %E5%95%86...。更奇怪的是,后端能收到路径和查询参数,却永远收不到 #评价。
看起来是一串地址,进入浏览器以后却像被拆开处理过:域名可能改写,中文可能编码,默认端口可能消失,相对路径还会和当前页面重新组合。只把 URL 当普通字符串,代码往往在最意外的地方出错。
URL 真正的作用,是把“要处理哪个资源”表达成一组有结构的数据。方案决定使用哪类规则,主机和端口决定去哪里,路径与查询决定找哪个目标,片段则留给客户端。后端解析、路由、缓存、鉴权和安全检查,都建立在这组结构之上。

初学 URL 时,很容易先看到一张集合图:URI 是大集合,URL 和 URN 是两个小集合。这个说法能帮助入门,但拿它指导现代 Web 编程会显得过于整齐。
URI 强调“标识”。一个标识符告诉系统我们正在谈论哪个资源,但它不承诺资源现在可访问,也不保证访问后一定得到文件。https://api.example.com/weather/today 可以标识“今天的天气”这种动态资源,它不是服务器磁盘上的某个固定文件。
URL 是 Web 平台实际解析和使用的地址形式。它通常同时给出标识以及访问所需的信息,例如方案、主机和路径。历史上,人们常用“URL 负责定位、URN 负责命名”来区分它们;现代规范和浏览器 API 更关心一串输入能否按 URL 规则解析,不要求开发者先判断它属于传统分类里的哪一个盒子。
IRI 解决的是可读字符范围的问题。传统 URI 主要使用有限的 ASCII 字符,而 IRI 允许直接出现 Unicode 字符,所以人可以看到 中文.example/课程/后端。进入只接受 URI 的协议或组件时,域名和路径还要分别转换成可传输形式:域名走国际化域名处理,路径等部分通常先转成 UTF-8 字节,再做百分号编码。
还要区分 URI、URI 引用和相对引用:
https://example.com/a 有自己的方案,是绝对形式。/images/logo.svg 没有方案和主机,是相对引用,需要一个基础 URL 才能得到完整结果。#install 只提供片段,也要依附在基础 URL 上解释。在后端日常开发中,不必反复争论某个值“到底叫 URL 还是 URI”。更有用的问题是:它有没有基础 URL、采用什么解析规则、哪些部分会进入 HTTP 请求、哪个组件允许用户控制。
同一串字符放在不同上下文里,意义可能不同。配置文件中的绝对地址、HTML 里的相对链接、HTTP 请求中的目标、路由框架里的路径参数,不能用同一套字符串拼接规则处理。
我们先看一条比较完整的地址:
https://api.shop.example:8443/v1/products/42?currency=CNY&from=home#reviews
\_____ / \___________________/ \______________/ \____________________/ \_____/
scheme authority path query fragment它可以概括成下面的通用结构:
scheme://authority/path?query#fragment不是每条 URL 都有全部组件,也不是每个方案都使用 //。例如 mailto:team@example.com 有方案,却不是 HTTP 那种层级地址。解析时应先让 URL 解析器识别方案,再按该方案的规则继续,而不是看见冒号就自行切字符串。
https 是方案(scheme)。它告诉客户端后面的内容要按 HTTPS URL 处理,并带出默认端口、传输安全和来源判断等规则。常见方案还有 http、ws、wss、file、data 和 mailto。
方案不完全等于“网络协议”。data: 把数据直接放在 URL 中,mailto: 描述邮件地址与可选字段,它们都不能套用“连接某台服务器并发 HTTP 请求”的思路。
方案名称不区分大小写,但工程中通常写成小写。对用户提供的 URL,后端应明确允许哪些方案。一个只想接收网页地址的功能,通常只应接受 https,或按业务需要接受 http,而不是默认接纳任意方案。
authority 位于 // 之后、下一个 /、? 或 # 之前。对 HTTP URL 来说,它主要包含主机和可选端口:
api.shop.example:8443
\______________/ \__/
host port主机可以是域名、IPv4 地址,或者用方括号包裹的 IPv6 地址。端口标识目标主机上的服务入口;https 省略端口时通常使用 443,http 省略时通常使用 80。显式写出默认端口与省略默认端口,在 HTTP 地址比较中通常可以视为等价,但非默认端口绝不能随便删除。
通用语法还允许在主机前出现 userinfo@。对 HTTP 和 HTTPS 来说,使用 userinfo 已经过时,而且特别容易误导人:
https://trusted.example@evil.example/account真正的主机是最后那个 evil.example,trusted.example 只是 @ 前的用户信息。安全判断如果用“字符串是否以可信域名开头”,就会在这里失效。
/v1/products/42 是路径(path),由 / 分隔为多个路径段。它看起来像文件路径,但后端完全可以把它映射到控制器、数据库查询、对象存储键或一段动态计算。
/v1/products/42
└─ 路由匹配 ─> 查询商品 42 ─> 生成 JSON 响应因此,/products/42 不意味着服务器上存在 products/42 这个文件。反过来,路径中出现 ../ 也不能简单当成普通文本;只要它最终参与文件系统访问,就必须在明确的根目录内做规范化和边界校验。
路径是否区分大小写、末尾斜杠是否代表另一个资源,都由服务端规则决定。/Users 与 /users、/docs 与 /docs/ 可能相同,也可能不同,客户端不能擅自假设。
? 后、# 前是查询(query)。Web 应用经常把它写成 key=value&key=value:
currency=CNY&from=home但通用 URL 语法只把查询看成一段数据,并没有规定它必须由键值对构成。键值对、& 分隔、重复参数如何处理,来自表单编码约定、框架或具体 API 的接口设计。
下面这些情况都需要接口自己定义:
tag=web&tag=backend 是两个值,还是后一个覆盖前一个?flag 是空值、布尔开关,还是非法参数?a=&b 中的空字符串和缺少等号是否等价?不要在一处用框架解析出的参数对象,在另一处又用原始查询字符串做签名或鉴权。两边对重复键、空值和顺序的理解不同,就会出现“验证的是一份数据,执行的是另一份数据”。
#reviews 是片段(fragment)。浏览器用它定位页面内元素、保存客户端路由状态,或者按文档类型解释某个局部位置。片段不会成为 HTTP 请求目标的一部分。
访问下面两个地址时,服务器收到的目标可以完全相同:
https://shop.example/products/42#details
https://shop.example/products/42#reviews这带来一个直接后果:服务端不能依靠片段完成鉴权、筛选或业务分支。OAuth 回调、下载授权或订单编号如果只放在 # 后面,普通后端处理器看不到它。浏览器中的脚本却能读取片段,所以“不会发给服务器”也不等于“适合保存秘密”。

你可能见过这样的转换:
咖啡 -> %E5%92%96%E5%95%A1它不是“一个汉字对应一个百分号代码”。百分号编码表达的是字节。过程分两步:先把字符编码成字节,再把需要转义的每个字节写成 %HH。
以 UTF-8 中的“咖”为例:
字符“咖”
↓ UTF-8
三个字节:E5 92 96
↓ 百分号编码
%E5%92%96十六进制字母大小写不影响字节值,%2f 和 %2F 表示同一个字节。为了输出一致,通常使用大写十六进制。
字母、数字以及 -、.、_、~ 属于未保留字符,一般可以直接出现。把它们编码后,标识的资源通常不应改变,例如 %7Euser 与 ~user 可以按语法规则归一。
另一类字符承担分隔作用,例如:
: / ? # [ ] @ ! $ & ' ( ) * + , ; =它们被称为保留字符。关键不在于“保留字符都要编码”,而在于它此刻是语法分隔符,还是数据本身。
假设一个路径段的真实数据是 a/b。如果直接拼进路径:
/files/a/b解析器会看到两个路径段。如果 / 是数据的一部分,就要按路径段的规则编码成 %2F:
/files/a%2Fb但这里还有一个现实问题:某些代理、Web 服务器或框架会拒绝编码后的斜杠,另一些组件会提前把它解码成 /。所以“编码正确”不保证整条链路理解一致。更稳妥的接口设计,是避免让资源标识符本身包含路径分隔符。
把整条 URL 先拼好再统一编码,往往会把真正的 :, /, ?, & 一起改掉。正确顺序是:先确定组件边界,再分别编码每个动态值,最后交给 URL 构造器组合。
const base = new URL('https://api.example.com/search')
base.searchParams.set('q', '咖啡 & 茶')
base.searchParams.set('page', '1')
console.log(base.href)
// https://api.example.com/search?q=%E5%92%96%E5%95%A1+%26+%E8%8C%B6&page=1URLSearchParams 使用表单风格的查询序列化,空格会写成 +,真正的加号则会编码。这里的 + 行为不是所有 URL 组件的通用规则:在路径中,+ 通常就是加号,空格通常写成 %20。
百分号本身作为数据时要写成 %25。如果已经编码的值又被编码一次:
原值:/docs
一次编码:%2Fdocs
再次编码:%252Fdocs后端链路中常有 CDN、网关、Web 服务器、框架和业务代码。只要其中两个组件都认为“该我解码”,%252e%252e 之类的输入就可能在后面重新变成特殊路径段。反过来,如果没人解码,路由又匹配不到原值。
安全边界上要确定一套顺序:先用同一个解析器得到组件,按约定解码一次,验证解码后的值,然后让后续层使用这份已经验证的数据。不要在鉴权后再对原字符串做第二轮解码。

encodeURI、encodeURIComponent、路径编码函数和查询参数序列化器保留的字符不同。名字都带“encode”,不代表可以互换。优先使用能明确表达组件的 API,例如 URL 对象和查询参数对象。
相对 URL 自己不能确定目标。它必须和基础 URL 一起解析。这正是同一个 logo.svg 放在两个页面里,会指向两个不同地址的原因。
假设基础 URL 是:
https://docs.example.com/guide/url/index.html?lang=zh#start不同引用的结果如下:
下面两个基础 URL 只差一个 /,结果却不同:
new URL('chapter', 'https://example.com/course/url').href
// https://example.com/course/chapter
new URL('chapter', 'https://example.com/course/url/').href
// https://example.com/course/url/chapter解析器不会请求服务器来判断 url 是文件还是目录。规则只看路径是否以 / 结尾:没有结尾斜杠,就把最后一段当成可替换部分;有结尾斜杠,就在其后追加。
这也是前端部署到子目录时常见的故障来源。页面能打开,脚本地址却从 /course/app.js 错误解析成 /app.js,最终得到 404。问题不一定在服务器,可能是构建时的基础 URL 或 HTML 中的 <base> 设置错了。
普通 HTML 文档通常使用当前文档地址作为基础 URL。页面里的第一个有效 <base href="..."> 可以改变文档内相对链接的解析基准。
<base> 很强,也很危险。它会影响页面中的大量链接、脚本、样式、表单和图片地址。若攻击者能注入或改变它,原本看起来安全的相对链接可能全部指向另一个站点。大多数项目更适合在构建和路由配置中明确部署前缀,而不是把 <base> 当作随手修复路径的工具。
解析层级 URL 时,. 表示当前层级,.. 表示上一级:
https://example.com/a/b/../c -> https://example.com/a/c但不要把 URL 路径消解和文件系统安全混为一谈。应用将 URL 路径映射到磁盘时,还要考虑平台路径分隔符、符号链接、编码斜杠和重复解码。最终文件路径必须确认仍位于允许的根目录内。
你可能会看到这些不同写法:
HTTPS://Example.COM:443/a/./b
https://example.com/a/b对 HTTPS 来说,它们可以规范化成相同结果:方案和主机转小写,删除默认端口,消解点路径段。缓存、爬虫和数据库如果完全按原字符串去重,会把它们当成两个键,造成重复抓取、缓存碎片或签名不一致。
%7E 还原为 ~。. 和 ..。/ 视为等价。这里的“通常”很重要。规范化不是一份能对所有方案、所有服务器盲目执行的替换清单。URL 语法只能给出一部分等价关系,剩下的要由方案和资源服务器决定。
/Avatar.png 和 /avatar.png 可能是两个资源。/docs 可能重定向到 /docs/,也可能代表不同路由。/a//b 不一定等于 /a/b。/ 与 %2F、? 与 %3F 可能改变组件边界。
登录回调要判断是否属于允许来源,缓存要生成键,爬虫要去重,业务代码要判断是否同一资源——这四件事需要的等价规则并不相同。
来源(origin)比较通常只关心方案、主机和有效端口:
https://shop.example:443/a
https://shop.example/b两者来源相同,但资源路径不同。若做重定向白名单,只验证“同源”可能正合适;若做对象权限判断,只验证同源就远远不够。
最危险的模式是:安全层按一种方式规范化并放行,下游代理或框架按另一种方式解析。验证前后的对象必须一致。与其反复改写原字符串,不如尽早解析成结构化字段,保留原始值用于审计,让安全判断和实际请求都使用同一个解析结果。
不是。? 开始查询,# 开始片段。片段一旦开始,后面的 ? 只是片段数据,不会重新开启查询。
https://example.com/search?q=url#result?page=2
\___/ \_____________/
query fragment服务器能看到 q=url,看不到 result?page=2。
浏览器发起 HTTP 请求时,路径和查询通常会进入请求目标。服务器、反向代理和 CDN 可能用它们选择路由或缓存对象。但“查询不同就一定缓存为两份”并不是统一保证,具体取决于缓存配置:有的忽略某些参数,有的按完整查询区分,有的先排序或过滤。
查询适合表达筛选、分页、排序等对资源视图的选择:
/products?category=coffee&page=2&sort=price它不适合放密码、会话令牌、长期 API 密钥或身份证号。HTTPS 只能保护传输途中不被旁观者读取,URL 仍可能进入浏览器历史、代理与服务器日志、监控系统、复制粘贴内容和截图。
把敏感字段从查询移到请求体可以减少许多意外暴露,但请求体也不是保险箱:仍然要使用 HTTPS、控制日志、限制访问权限并设置合理保留周期。选择 GET 或 POST 还要考虑方法语义、缓存与幂等性,不能只看“哪个能藏参数”。
HTML 页面常用 #section-id 滚动到具有相应标识的元素。单页应用也可能用 #/users/42 表示客户端路由。片段的具体含义由拿到资源表示的客户端和内容类型决定,不是服务器通用定义的一组参数。
片段变化通常不触发一次新的服务器请求,这对纯前端状态很方便,代价是:服务端日志、服务端路由和常规 HTTP 缓存都不了解这段状态。需要服务端渲染、分享预览或后端鉴权的状态,通常应进入路径、查询或请求体,而不是只藏在片段里。

国际化 URL 涉及两条不同的转换路径:主机名按国际化域名规则处理,路径、查询和片段则按各自的 URL 组件规则处理。把整条地址统一做一次 Punycode 或统一做一次百分号编码,都会出错。
中文域名方便人阅读,但 DNS 需要可互操作的 ASCII 形式。域名标签会经过映射和校验,其中含非 ASCII 字符的标签再转换成以 xn-- 开头的 ASCII 兼容形式;原本就是 ASCII 的标签不需要这一步。浏览器地址栏可能显示 Unicode,也可能因为安全策略显示 ASCII 形式。
这不是“浏览器随机变乱码”。人看到的形式和网络处理的形式可以不同,但解析后应指向同一个规范化主机。后端做主机白名单时,应使用成熟 URL/IDNA 库得到的规范化主机名比较,不要直接对用户输入做小写转换后就结束。
路径 /课程/后端 可以以可读的 Unicode 形式展示,序列化或传输时则可能看到:
/%E8%AF%BE%E7%A8%8B/%E5%90%8E%E7%AB%AF这和域名的 xn-- 转换不是一回事。查询参数也有自己的序列化规则;特别是表单风格的查询会把空格写成 +。所以国际化 URL 没有一个“对整串文字执行即可”的万能编码函数。
不同文字系统中存在外形接近的字符。攻击者可以注册一个看起来像知名站点、实际代码点不同的域名。Punycode 解决的是传输与解析,不会自动消除视觉混淆。
后端不能根据截图、显示文本或“肉眼像不像”决定是否可信。支付回调、Webhook、跳转目标等场景,应比较解析和规范化后的精确主机,限制允许的方案与端口,并把子域名边界判断清楚:
pay.example.com 是 example.com 的子域名
pay-example.com 不是
example.com.evil.test 也不是
URL 安全问题很少只是“有没有特殊字符”。更常见的根源是两个组件对同一输入理解不同:网关认为主机是 A,业务代码认为是 B;鉴权时路径还含 %2F,路由时已经解码成 /;白名单只检查首次请求,HTTP 客户端却自动跟随重定向去了内网。
下面这些检查都不可靠:
input.startsWith('https://example.com')
input.includes('example.com')
input.endsWith('example.com')它们会把 userinfo、相似域名、额外后缀和编码差异混在一起。应先解析,再比较结构化字段:
function isAllowedRedirect(input) {
const url = new URL(input, 'https://app.example.com')
return url.protocol === 'https:' &&
url.hostname === 'app.example.com' &&
url.port === ''
}如果允许子域名,应先确认它是否真以点边界结束,例如主机等于 example.com,或者以 .example.com 结尾。即便这样,也要确认所有这些子域都属于同一信任范围。
图片抓取、网页预览、Webhook 测试和“从 URL 导入文件”都会让服务器替用户访问地址。此时用户不只是提供一段文本,而是在影响服务器向哪里建立连接。
至少要控制:
https,不要让 file:、本地套接字或其他处理器混进来。域名白名单只解决“名字看起来允许”,不等于最终连接地址一定允许。反过来,只在第一次 DNS 解析后保存一个结果,也未必覆盖客户端实际建立连接时的解析行为。
/users/123/orders/9 看起来属于用户 123,不代表当前登录者就是 123。URL 负责标识目标,身份和权限仍要由服务端根据可信会话与数据关系验证。
可预测 ID 也不天然是漏洞。真正的问题是服务端是否对每次访问执行对象级授权。把 9 改成随机 UUID 可以降低猜测概率,却不能代替权限检查。
原始 URL 对分析解析差异很有价值,但它也可能携带个人信息和令牌。较稳妥的做法是:
如果把本章压缩成一个后端处理流程,可以按下面的顺序做。
使用平台提供的 URL 解析器,并明确是否允许相对引用。需要绝对地址时,不要偷偷补基础 URL;需要站内相对跳转时,则提供固定、可信的基础 URL。
function parseAbsoluteHttpUrl(input) {
const url = new URL(input)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('仅允许 HTTP(S) URL')
}
return url
}解析成功只说明语法可接受,不说明业务可信。随后还要验证方案、主机、有效端口、路径范围、查询字段和长度限制。
不要这样拼:
const url = '/search?q=' + userInput + '&page=' + page当 userInput 含有 &page=999 时,它会改变查询结构。改用组件 API:
const url = new URL('/search', 'https://shop.example.com')
url.searchParams.set('q', userInput)
url.searchParams.set('page', String(page))同样的原则适用于路径。若框架提供路由参数生成器,就让它编码单个路径段;不要把未经处理的用户输入直接塞进路径模板。
URL 没有一个适用于所有浏览器、服务器、代理和应用的“统一最大长度”。HTTP 组件应支持的下限、浏览器能力与生产配置也不是同一概念。设计接口时,应根据入口网关、CDN、服务器和框架中最小的实际限制设定预算,并对超限请求给出明确错误。
大量、复杂或敏感的数据通常不适合塞进 URL,但改用请求体之后仍要设置体积限制。长度控制是资源与兼容性设计,不是把 GET 换成 POST 就自动解决。
URL 处理测试不能只调用一个工具函数。至少让请求经过和生产一致的代理、服务器与框架,覆盖:
%2F、%25、无效百分号序列和重复编码。. 与 ..。你会发现,URL 的难点从来不是背下五个组成部分,而是让“浏览器怎么解析、网关怎么转发、框架怎么解码、业务怎么验证”对同一个目标达成一致。下一次看到路由匹配异常、缓存命中混乱或回调地址校验失败时,先别急着加字符串替换;先把 URL 按组件拆开,问题通常就已经露出一半了。