Skip to content

Urlencode踩坑日记

2020年3月31日

Urlencode又称百分号编码,是一种很常用的编码方式,作为前端工程师,少不了与它要打交道。不管是GET请求发送参数,还是POST请求发送body,都少不了要使用Urlencode来编码。

而Urlencode的编码规则又特别简单:取出字符的ASCII码,转成16进制,然后前面加上百分号即可。如果是多字节的字符,则取出每一字节,按照同样的规则进行转换即可。例如问号?的ASCII码为63,转换为16进制为3F,所以%3F即为?进行Urlencode编码的结果。

urlencode

背景

项目需要对外提供HTTP API接口,因此接口鉴权成为一个很重要的内容。为了确保安全,防止中间人篡改数据或进行重放攻击,双方约定的私钥不可以直接出现在请求中,因此采用请求签名的方式来鉴权。

双方约定appKey和appSecret,其中appKey用于识别请求对象,appSecret用于请求签名。具体的方案如下:

  1. 客户端按照当前时间生成时间戳timestamp和随机数nonce
  2. 客户端按照指定的规则将HTTP请求的queryString和POST的body进行编码,得到一个字符串data
  3. timestampnoncedata按规则拼接,然后使用appSecret计算签名
  4. appKeytimestampnonce和签名一起随请求发出

服务端在接到请求后将使用获得的数据和appSecret重新计算签名,然后判断与客户端给出的签名值是否一致,如果不一致则鉴权不通过。

这一套鉴权机制可以有效防御一些攻击手段:

  • 使用了时间戳,可以避免过期请求被重发
  • 使用了随机数,可以避免请求被短时间重复发送
  • 签名数据包含了完整的时间戳、随机数和请求数据,保证服务端收到的确实是客户端发送的数据,避免被拦截修改
  • 签名的密钥是双方协商好的,避免请求伪造

踩坑

在上面的鉴权过程中,一个非常重要的点就是第2点,即将请求的queryString和POST的body进行编码,得到一个字符串。

因为GET和POST请求中,数据都会被Urlencode编码,因此很容易想到,我们也使用Urlencode来进行这个鉴权前的编码过程。

于是坑就这么不期而遇了。

由于服务端和客户端都使用JavaScript编写,因此都使用了encodeURIComponent来进行Urlencode编码,并且过程相当愉快。

但既然是开放接口,就早晚会面临各种各样的客户端。于是在我自己编写的PHP客户端上,踩坑了:有时候请求一切正常,有时候却鉴权无法通过。经过反复的调试分析,最终发现,PHP获取的待签名的字符串和JS获取的不一样,而问题就出在对*的转义上。

在JS中,encodeURIComponent并不会对*进行转义,而PHP中rawurlencode却会将*转义为%2A。因此,同样的数据在不同的客户端中就产生了不同的字符串,最终导致计算出的签名值不同,鉴权失败。

爬坑失败

如果一个接口一直使用都没有问题,突然来了一个新的客户端就鉴权失败,那么必然是这个客户端有问题了。在这种想法的驱使下,对PHP这个世界上最好的语言好感度再次-1,然后硬着头皮去查资料。发现确实有很多人碰到了PHP在使用Urlencode编码的时候星号被编码的问题。还有人给出了解决问题的代码,即在rawurlencode之后再将%2A替换成*

于是就这么更新上线了,一切又恢复了正常。

然而好景不长,才刚正常几天,又出现了诡异的鉴权失败的问题。而这一次,请求的内容是[链接](https://www.qq.com)。再次对比后,发现括号()在Urlencode后又不一致了:JS没有对括号进行转义,而PHP对它们进行了转义,于是再次出现签名不一致的问题。

直觉告诉我,当一个问题第一次出现时,也许可以绕得过去,但是当它再一次出现的时候,就必须得挖到底了,否则未来一定会有更严重的问题出现。

认真审视Urlencode

回到问题本质:兼容性问题。这可是前端工程师最擅长的领域,于是很自然地想到——规范。urlencode的规范是RFC3986,但是看完规范之后,并没能解决这个兼容性的问题,反而解释了兼容性的来源:很多字符是否进行编辑取决于具体的场景和实现……

下面这一段有点烧脑,如果不是特别有兴趣,建议跳过。

规范将保留字符分为gen-delimssub-delims两部分:

  • gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
  • sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="

然后定义了pcharunreserved指除了保留字符之外的字符)

  • pchar = unreserved / pct-encoded / sub-delims / ":" / "@"

以URL中出现的pathquery为例,它们的规则分别是

path          = path-abempty    ; begins with "/" or is empty
                    / path-absolute   ; begins with "/" but not "//"
                    / path-noscheme   ; begins with a non-colon segment
                    / path-rootless   ; begins with a segment
                    / path-empty      ; zero characters

      path-abempty  = *( "/" segment )
      path-absolute = "/" [ segment-nz *( "/" segment ) ]
      path-noscheme = segment-nz-nc *( "/" segment )
      path-rootless = segment-nz *( "/" segment )
      path-empty    = 0<pchar>

      segment       = *pchar
      segment-nz    = 1*pchar
      segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" )
                    ; non-zero-length segment without any colon ":"
path          = path-abempty    ; begins with "/" or is empty
                    / path-absolute   ; begins with "/" but not "//"
                    / path-noscheme   ; begins with a non-colon segment
                    / path-rootless   ; begins with a segment
                    / path-empty      ; zero characters

      path-abempty  = *( "/" segment )
      path-absolute = "/" [ segment-nz *( "/" segment ) ]
      path-noscheme = segment-nz-nc *( "/" segment )
      path-rootless = segment-nz *( "/" segment )
      path-empty    = 0<pchar>

      segment       = *pchar
      segment-nz    = 1*pchar
      segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" )
                    ; non-zero-length segment without any colon ":"
query       = *( pchar / "/" / "?" )
query       = *( pchar / "/" / "?" )

可以看到,它们都有引用pchar作为规则(或规则的一部分),除此之外,还有各自允许的字符。这中间的细节要弄明白需要花非常多的时间,我们也可以先不纠结,虽然规范中写了每个部分可以包含哪些字符,却并没有明确写出这些字符是否需要进行urlencode(例如sub-delims)。

规范中唯一能给我们一些比较明确指引的只有对unreserved非保留字符的描述,明确定义了它们是字母 / 数字 / "-" / "." / "_" / "~"这几个字符。

回到现实

既然规范无法给出足够明确的指引,就只能看看现实世界是怎么运作的了。在搜索urlencode规范的时候,发现有很多文档都是这么写:

按照rfc3986,除字母、数字、-._~字符外,其它字符均需要进行百分号编码。

也即,大家在实际应用时,会把除非保留字符之外的其他字符全部进行编码。

那编程语言又是如何处理的呢?于是拿JavaScript、PHP、Python分别跑了一下。由于JS中有encodeURI/encodeURIComponent两个方法,PHP有urlencode/rawurlencode两个方法,因此一共有5组结果。

ASCIIcharpythonjs encodeURIjs encodeURIComponentphp urlencodephp rawurlencode
0NUT 空字符(Null)%00%00%00%00%00
1SOH 标题开始%01%01%01%01%01
2STX 本文开始%02%02%02%02%02
3ETX 本文结束%03%03%03%03%03
4EOT 传输结束%04%04%04%04%04
5ENQ 请求%05%05%05%05%05
6ACK 确认回应%06%06%06%06%06
7BEL 响铃%07%07%07%07%07
8BS 退格%08%08%08%08%08
9HT 水平定位符号TAB%09%09%09%09%09
10LF 换行键%0A%0A%0A%0A%0A
11VT 垂直定位符号%0B%0B%0B%0B%0B
12FF 换页键%0C%0C%0C%0C%0C
13CR Enter回车键%0D%0D%0D%0D%0D
14SO 取消变换%0E%0E%0E%0E%0E
15SI 启用变换%0F%0F%0F%0F%0F
16DLE 跳出数据通讯%10%10%10%10%10
17DC1 设备控制一%11%11%11%11%11
18DC2 设备控制二%12%12%12%12%12
19DC3 设备控制三%13%13%13%13%13
20DC4 设备控制四%14%14%14%14%14
21NAK 确认失败回应%15%15%15%15%15
22SYN 同步用暂停%16%16%16%16%16
23TB 区块传输结束%17%17%17%17%17
24CAN 取消%18%18%18%18%18
25EM 连接介质中断%19%19%19%19%19
26SUB 替换%1A%1A%1A%1A%1A
27ESC 退出键%1B%1B%1B%1B%1B
28FS 文件分区符%1C%1C%1C%1C%1C
29GS 组群分隔符%1D%1D%1D%1D%1D
30RS 记录分隔符%1E%1E%1E%1E%1E
31US 单元分隔符%1F%1F%1F%1F%1F
32空格%20%20%20+%20
33!%21!!%21%21
34"%22%22%22%22%22
35#%23#%23%23%23
36$%24$%24%24%24
37%%25%25%25%25%25
38&%26&%26%26%26
39'%27''%27%27
40(%28((%28%28
41)%29))%29%29
42*%2A**%2A%2A
43+%2B+%2B%2B%2B
44,%2C,%2C%2C%2C
45------
46......
47///%2F%2F%2F
48000000
49111111
50222222
51333333
52444444
53555555
54666666
55777777
56888888
57999999
58:%3A:%3A%3A%3A
59;%3B;%3B%3B%3B
60<%3C%3C%3C%3C%3C
61=%3D=%3D%3D%3D
62>%3E%3E%3E%3E%3E
63?%3F?%3F%3F%3F
64@%40@%40%40%40
65AAAAAA
66BBBBBB
67CCCCCC
68DDDDDD
69EEEEEE
70FFFFFF
71GGGGGG
72HHHHHH
73IIIIII
74JJJJJJ
75KKKKKK
76LLLLLL
77MMMMMM
78NNNNNN
79OOOOOO
80PPPPPP
81QQQQQQ
82RRRRRR
83SSSSSS
84TTTTTT
85UUUUUU
86VVVVVV
87WWWWWW
88XXXXXX
89YYYYYY
90ZZZZZZ
91[%5B%5B%5B%5B%5B
92\%5C%5C%5C%5C%5C
93]%5D%5D%5D%5D%5D
94^%5E%5E%5E%5E%5E
95______
96`%60%60%60%60%60
97aaaaaa
98bbbbbb
99cccccc
100dddddd
101eeeeee
102ffffff
103gggggg
104hhhhhh
105iiiiii
106jjjjjj
107kkkkkk
108llllll
109mmmmmm
110nnnnnn
111oooooo
112pppppp
113qqqqqq
114rrrrrr
115ssssss
116tttttt
117uuuuuu
118vvvvvv
119wwwwww
120xxxxxx
121yyyyyy
122zzzzzz
123{%7B%7B%7B%7B%7B
124|%7C%7C%7C%7C%7C
125}%7D%7D%7D%7D%7D
126~~~~%7E~
127删除%7F%7F%7F%7F%7F

总结一下:

  • 非保留字符的处理上,非常一致(除了~
  • JS的encodeURI方法保留了很多符号,这些符号没有进行编码
  • JS的encodeURIComponent方法相比PHP和Python,少了!'*这5个字符的编码
  • PHP的urlencode方法将空格编码成了加号(+),且对~进行不必要的编码
  • Python默认没有对/进行编码,需要显式指定safe=''才会进行编码(urllib.parse.quote(str, safe='')

如果按照业界“非保留字符一律进行编码”的实践规则来看,那么Python(指定safe='')和PHP(rawurlencode)是符合要求的,而JS的encodeURIComponent则需要针对额外的5个字符打补丁。

javascript
function fixedEncodeURIComponent (str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
    return '%' + c.charCodeAt(0).toString(16);
  });
}
function fixedEncodeURIComponent (str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
    return '%' + c.charCodeAt(0).toString(16);
  });
}

善后

因为结论比较明显,业界和主流语言都采用了比较一致的规则,因此最终这个项目的鉴权部分也进行了修改,除了非保留字符外,其他的字符都需要进行百分号编码。

urlencode由于规范中没有规定得非常细致,将很多细节交给了实现,因此导致各语言的处理并不一致。当然如果能提前预知有这样的问题,有可能在选择方案的时候就不会选择urlencode这么一种“不太确定”的编码规则。

如果你认为JSON大法好,恭喜你即将进行另一个坑:不同语言在JSON编码的处理上也不一致,例如中文要不要变成unicode格式、斜杠要不要编码等等。

大概一位前端工程师也没想到有一天需要在后端处理“兼容性问题”。好在处理兼容性问题的原则是一致的:找到差异点、抹平它。