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

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

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

回到现实

既然规范无法给出足够明确的指引,就只能看看现实世界是怎么运作的了。在搜索urlencode规范的时候,发现有很多文档都是这么写:按照rfc3986,除字母、数字、-、.、_、~字符外,其它字符均需要进行百分号编码。

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

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

(因知乎不支持Markdown,表格比较难排版,省略。想看详细情况的请查看原文。)

总结一下:非保留字符的处理上,非常一致(除了~)

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);
});
}

善后

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

urlencode由于规范中没有规定得非常细致,将很多细节交给了实现,因此导致各语言的处理并不一致。当然如果能提前预知有这样的问题,有可能在选择方案的时候就不会选择urlencode这么一种“不太确定”的编码规则。如果你认为JSON大法好,恭喜你即将进行另一个坑:不同语言在JSON编码的处理上也不一致,例如中文要不要变成unicode格式、斜杠要不要编码等等。

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