Urlencode踩坑日记
2020年3月31日
Urlencode又称百分号编码,是一种很常用的编码方式,作为前端工程师,少不了与它要打交道。不管是GET请求发送参数,还是POST请求发送body,都少不了要使用Urlencode来编码。
而Urlencode的编码规则又特别简单:取出字符的ASCII码,转成16进制,然后前面加上百分号即可。如果是多字节的字符,则取出每一字节,按照同样的规则进行转换即可。例如问号?
的ASCII码为63
,转换为16进制为3F
,所以%3F
即为?
进行Urlencode编码的结果。
背景
项目需要对外提供HTTP API接口,因此接口鉴权成为一个很重要的内容。为了确保安全,防止中间人篡改数据或进行重放攻击,双方约定的私钥不可以直接出现在请求中,因此采用请求签名的方式来鉴权。
双方约定appKey和appSecret,其中appKey用于识别请求对象,appSecret用于请求签名。具体的方案如下:
- 客户端按照当前时间生成时间戳
timestamp
和随机数nonce
- 客户端按照指定的规则将HTTP请求的queryString和POST的body进行编码,得到一个字符串
data
- 将
timestamp
、nonce
和data
按规则拼接,然后使用appSecret
计算签名 - 将
appKey
、timestamp
、nonce
和签名一起随请求发出
服务端在接到请求后将使用获得的数据和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-delims
和sub-delims
两部分:
gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
然后定义了pchar
(unreserved
指除了保留字符之外的字符)
pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
以URL中出现的path
和query
为例,它们的规则分别是
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组结果。
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个字符打补丁。
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格式、斜杠要不要编码等等。
大概一位前端工程师也没想到有一天需要在后端处理“兼容性问题”。好在处理兼容性问题的原则是一致的:找到差异点、抹平它。