编程 验签总差在 `%21` 和 `!` 之间:空格、`~`、`!` 在三套 URL 编码规则里的分歧

2026-11-12 00:04:04

验签总差在 %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 urlencodea+b%2Bc%7Ed%21e%26f%3D%E4%B8%AD
PHP rawurlencodea%20b%2Bc~d%21e%26f%3D%E4%B8%AD
Go url.QueryEscapea+b%2Bc~d%21e%26f%3D%E4%B8%AD
Go url.PathEscapea%20b+c~d%21e&f=%E4%B8%AD
JS encodeURIComponenta%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,先怀疑链路上谁又编了一遍。

五、排查顺序

  1. 以 Request URL 为准,不看地址栏和 Network 的展示值。
  2. 逐层 unescape(不是一次到底),看每一层剥出什么。
  3. 定位哪一层多编:网关、SDK、签名工具常各编一次。
  4. 固定单一编码层,接口文档里写清谁编谁解,其余层透传。
  5. 签名要严格 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 后面和在直连场景下,行为可能不一样。

复制全文 生成海报 URL编码 PHP Go JavaScript 签名 接口对接

推荐文章

程序员茄子在线接单