首页 > 网站优化 > Urlencode踩坑日记,Urlencode需要注意的“坑”

Urlencode踩坑日记,Urlencode需要注意的“坑”

08-03 12:45 网站优化

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、timestamp、nonce和data按规则拼接,然后使用appSecret计算签名

 

4、appKey、timestamp、nonce和签名一起随请求发出

 

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

 

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

 

1、使用了时间戳,可以避免过期请求被重发

 

2、使用了随机数,可以避免请求被短时间重复发送;

 

3、签名数据包含了完整的时间戳、随机数和请求数据,保证服务端收到的确实是客户端发送的数据,避免被拦截修改;

 

4、签名的密钥是双方协商好的,避免请求伪造。

 

踩坑

 

在上面的鉴权过程中,一个非常重要的点就是第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 = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="

 

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

 

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

 

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

 

Urlencode-

 

可以看到,它们都有引用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个字符打补丁。

 

rawurlencode

 

善后

 

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

 

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

 

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

 

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

 

负面压制-QiuTian

2020-08-03

版权保护: 转载请保留链接: http://www.qiutianseo.com/a/365.html