验签总差在 %21 和 ! 之间:空格、~、! 在三套 URL 编码规则里的分歧
规范与源码先放在前面,后面每一条判据都能在这里对上:
- RFC 3986 §2.1:
- Go 编码表生成:
- Go
escape实现:
一、现象:日志里两边一样,字节上差三个
对接支付宝、微信、AWS 这类要求 RFC 3986 风格签名的接口时,参数值里只要有一个空格、一个感叹号或一个中文,前端 JS 签、后端 PHP 验,签名串就差几个字符。日志里两边看着完全一致,因为浏览器地址栏和 Network 面板都会把编码结果渲染回人可读形式,%21 显示成 !,%20 显示成空格。
判据很直接:把两边的签名原文打进日志,逐字节比。差异只落在三处——%21 与 !、%7E 与 ~、+ 与 %20。找到这三处,问题基本就定位了。
二、三套规则并存
RFC 3986 的 percent-encoding:空格只有 %20 一种写法;非保留字符只有 A-Z a-z 0-9 - . _ ~,其余一律 %XX,十六进制建议大写。
application/x-www-form-urlencoded:HTML 表单那一套,空格写成 +,字面加号写成 %2B。它和 RFC 3986 在空格这一位上是互斥的。
各语言历史实现:PHP 的 urlencode、JS 的 escape、Go 的 QueryEscape,在保留集上各有偏差。三套规则同时存在于一个请求链路上,是验签对不上的根源。
三、同一串输入,五个函数
输入 a b+c~d!e&f=中:
| 函数 | 输出 |
|---|---|
PHP urlencode | a+b%2Bc%7Ed%21e%26f%3D%E4%B8%AD |
PHP rawurlencode | a%20b%2Bc~d%21e%26f%3D%E4%B8%AD |
Go url.QueryEscape | a+b%2Bc~d%21e%26f%3D%E4%B8%AD |
Go url.PathEscape | a%20b+c~d%21e&f=%E4%B8%AD |
JS encodeURIComponent | a%20b%2Bc~d!e%26f%3D%E4%B8%AD |
可复现:
php -r 'echo urlencode("a b+c~d!e&f=中"),"\n",rawurlencode("a b+c~d!e&f=中"),"\n";'
fmt.Println(url.QueryEscape("a b+c~d!e&f=中"))
fmt.Println(url.PathEscape("a b+c~d!e&f=中"))
node -e 'console.log(encodeURIComponent("a b+c~d!e&f=中"))'
五行输出,空格、~、!、&、= 五处各不相同。签名串就是这些字符拼起来的,差一位验签就失败。
四、互斥点逐条
1. 空格:+ 还是 %20
QueryEscape 走 +,PathEscape 走 %20。Go 源码 escape() 里对空格是特判:case c == ' ' && mode == encodeQueryComponent 写 '+',不是查表结果。反向也不对称,源码注释里写明了 PathUnescape 与 QueryUnescape 的唯一区别是「does not unescape '+' to ' '」。所以 path 里的 + 是字面加号,query 里的 + 是空格。同一份代码两种语义,取决于字符落在 URL 的哪一段。
2. ~:%7E 还是 ~
PHP urlencode 输出 %7E,rawurlencode 保留 ~(RFC 3986 非保留集含 ~)。AWS SigV4、支付宝要求的正是后者。用 urlencode 做签名,客户名里带 ~ 就验签失败,而且只在部分客户身上复现——这类问题最难排。
3. ! ' ( ) *:编还是不编
JS encodeURIComponent 不编码这五个,它的保留集是 A-Za-z0-9 - _ . ! ~ * ' ( ),比 RFC 3986 宽;PHP rawurlencode 与 Go 都会编码。跨语言验签最常见的就是这一处:PHP 得 %21,JS 得 !。签名串对不上时先数 ! 的个数。
4. PathEscape 保留 & = + : @
Go gen_encoding_table.go 的 encodePathSegment 分支原话如此,RFC 3.3 允许 : @ & = + $ 作为 pchar,Go 只转义 / ; , ?。后果:路径段里 + 原样发出,多数实现当字面加号,但部分网关和 ASP.NET 系路由会把 path 里的 + 解成空格。路径里塞 base64 或带 + 的时间戳会坏,且坏在服务端解出来的值上,不在编码结果上。
5. % 本身与双重编码
编码结果里出现 %25,就是多编了一层。%20 → %2520,解码一次是 %20,两次才是空格。签名原文里出现 %25,先怀疑链路上谁又编了一遍。
五、排查顺序
- 以 Request URL 为准,不看地址栏和 Network 的展示值。
- 逐层 unescape(不是一次到底),看每一层剥出什么。
- 定位哪一层多编:网关、SDK、签名工具常各编一次。
- 固定单一编码层,接口文档里写清谁编谁解,其余层透传。
- 签名要严格 RFC 3986 时:PHP 用
rawurlencode;Go 用url.QueryEscape再把+换成%20(Go 本身已保留~,不需要再处理);JS 不要直接用encodeURIComponent拼签名,! ' ( ) *四个字符要自己补转义。
第 5 条里的 ! ' ( ) * 实际是五个字符,encodeURIComponent 全部放行,补转义时别漏 *。
六、边界
中文 3 字节变成 9 个 ASCII 字符,网关 8192 的 URL 上限按编码后算,不是按原文算。%00 该拒。URL 编码不是 XSS/SQL 注入的防御,它只解决字符传输问题。
path 里 %2F 是否被网关提前解码、是否触发 merge_slashes,各实现不同,这部分我不在统一结论上给判据,需要按自己的网关实测:构造一个带 %2F 的路径段,看服务端路由拿到的是一个段还是两段,再看 // 会不会被折叠。同一份 Go 代码在 Nginx 后面和在直连场景下,行为可能不一样。