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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 ":"
1
query       = *( pchar / "/" / "?" )

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

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

回到现实

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

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

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

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

ASCII char python js encodeURI js encodeURIComponent php urlencode php rawurlencode
0 NUT 空字符(Null) %00 %00 %00 %00 %00
1 SOH 标题开始 %01 %01 %01 %01 %01
2 STX 本文开始 %02 %02 %02 %02 %02
3 ETX 本文结束 %03 %03 %03 %03 %03
4 EOT 传输结束 %04 %04 %04 %04 %04
5 ENQ 请求 %05 %05 %05 %05 %05
6 ACK 确认回应 %06 %06 %06 %06 %06
7 BEL 响铃 %07 %07 %07 %07 %07
8 BS 退格 %08 %08 %08 %08 %08
9 HT 水平定位符号TAB %09 %09 %09 %09 %09
10 LF 换行键 %0A %0A %0A %0A %0A
11 VT 垂直定位符号 %0B %0B %0B %0B %0B
12 FF 换页键 %0C %0C %0C %0C %0C
13 CR Enter回车键 %0D %0D %0D %0D %0D
14 SO 取消变换 %0E %0E %0E %0E %0E
15 SI 启用变换 %0F %0F %0F %0F %0F
16 DLE 跳出数据通讯 %10 %10 %10 %10 %10
17 DC1 设备控制一 %11 %11 %11 %11 %11
18 DC2 设备控制二 %12 %12 %12 %12 %12
19 DC3 设备控制三 %13 %13 %13 %13 %13
20 DC4 设备控制四 %14 %14 %14 %14 %14
21 NAK 确认失败回应 %15 %15 %15 %15 %15
22 SYN 同步用暂停 %16 %16 %16 %16 %16
23 TB 区块传输结束 %17 %17 %17 %17 %17
24 CAN 取消 %18 %18 %18 %18 %18
25 EM 连接介质中断 %19 %19 %19 %19 %19
26 SUB 替换 %1A %1A %1A %1A %1A
27 ESC 退出键 %1B %1B %1B %1B %1B
28 FS 文件分区符 %1C %1C %1C %1C %1C
29 GS 组群分隔符 %1D %1D %1D %1D %1D
30 RS 记录分隔符 %1E %1E %1E %1E %1E
31 US 单元分隔符 %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
48 0 0 0 0 0 0
49 1 1 1 1 1 1
50 2 2 2 2 2 2
51 3 3 3 3 3 3
52 4 4 4 4 4 4
53 5 5 5 5 5 5
54 6 6 6 6 6 6
55 7 7 7 7 7 7
56 8 8 8 8 8 8
57 9 9 9 9 9 9
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
65 A A A A A A
66 B B B B B B
67 C C C C C C
68 D D D D D D
69 E E E E E E
70 F F F F F F
71 G G G G G G
72 H H H H H H
73 I I I I I I
74 J J J J J J
75 K K K K K K
76 L L L L L L
77 M M M M M M
78 N N N N N N
79 O O O O O O
80 P P P P P P
81 Q Q Q Q Q Q
82 R R R R R R
83 S S S S S S
84 T T T T T T
85 U U U U U U
86 V V V V V V
87 W W W W W W
88 X X X X X X
89 Y Y Y Y Y Y
90 Z Z Z Z Z Z
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
97 a a a a a a
98 b b b b b b
99 c c c c c c
100 d d d d d d
101 e e e e e e
102 f f f f f f
103 g g g g g g
104 h h h h h h
105 i i i i i i
106 j j j j j j
107 k k k k k k
108 l l l l l l
109 m m m m m m
110 n n n n n n
111 o o o o o o
112 p p p p p p
113 q q q q q q
114 r r r r r r
115 s s s s s s
116 t t t t t t
117 u u u u u u
118 v v v v v v
119 w w w w w w
120 x x x x x x
121 y y y y y y
122 z z z z z z
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个字符打补丁。

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

善后

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

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

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

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