记一次换行引发的血案

提醒:本文最后更新于 2108 天前,文中所描述的信息可能已发生改变,请谨慎使用。

话说最近真是流年不利,感觉各种BUG犹如天灾一样全部冒出来了,这不昨天又解了一个非常无语的问题,大概是关于换行和正则的臭虫,下面给大家吐槽一下。

数据“野”了

昨天同事反馈某个页面的数据没有正常显示,最开始我还以为是接口没有返回数据,结果看了下请求发现接口有正常的数据呀。没办法就一路反查回去,最后查到居然代码里接口请求抛错了?!因为定义了 Promise 的 catch 流程,所以也没有把错误抛出来。因为之前这个页面都是正常的,很久都没有改动所以我第一反应是这个数据异常了,查了半天的数据格式问题。可是问题就在于明明看到数据是正常的呀,服务端没有报错,接口数据也是可以正常解析的。最后我突然想起来,我们的接口是 JSONP 的会不会是 JSONP 功能挂了?查了一下果然是这样。

Jietu20180616-104611.jpg

我们都知道 JSONP 需要定义一个 callback 回调名称,最后数据加载的时候执行这个回调返回数据才算成功。可以看到图里面虽然加了 callback=? 但是并没有对 ? 进行替换,服务端那边应该也是增加了校验这种情况下并没有给我们添加回调方法名。实际上服务端这里的处理都是没问题的,因为即使 ? 被添加到数据里了也会有问题,因为 ? 是逻辑运算符并不能被定义成变量。而没有加回调方法的后果就是虽然接口返回正常,但是最终数据没有人接收就“野”了。

我的回调呢?

我们在内部是使用了 zepto 这个基础库的。由于添加回调方法名并做替换肯定是 zepto 内部的行为,所以当我查到这的时候我都懵了。难道一年难遇一次国际知名库的 BUG 就这样被我水逆的碰到了?怀着一脸惊讶的表情我打开了 zepto 的文件继续查了下去。最终我发现还真是 zepto 里面 callback=? 没有替换成功。定义好 callback 方法之后 zepto 里面是这么去替换接口地址的参数的:

// https://github.com/madrobby/zepto/blob/master/src/ajax.js#L121-L126
window[callbackName] = function(){
  responseData = arguments
}

script.src = options.url.replace(/\?(.+)=\?/, '?$1=' + callbackName)
document.head.appendChild(script)

通过正则匹配到两个 ? 后并替换最后一个 ? 为回调方法名。最开始我一直没绕过来,我在想这特么是什么骚操作?能匹配到 callback=? 么??callback=? 是可以匹配到,万一这个东西在后面 &callback=? 不就挂了么?最后才反应过来它这么做是直接匹配了整个地址后的 query,忽略了参数名称直接匹配 ?。对于这种操作,我只能说:

骚,真是骚

那么在当前的情况下这种操作会有什么问题呢,我怀着不服输的精神又查了下去。结果我发现在这种情况下这个正则居然跪了!最后我在这打印出来看,发现传进去的 URL 里面多了一个回车符,导致这段正则失效了。因为我们知道正则里面的 . 元字符是匹配除了回车符以外的所有字符。(这无语的BUG…果然水逆就算是啥也不干问题也会自动找上门啊…

当我查到这的时候我就思考了两个问题:

  1. 这个回车从哪里来的?参数里怎么会有回车?因为这个参数是直接从当前页的 URL 获取的,所以是我们在拼接的时候操作的有问题,还是服务端下发的当前地址就有问题?
  2. 为什么到了最后 URL 地址这还有回车,正常情况下到这步的时候应该库都会对其进行转码编译了,例如回车符会被编成 %0A 这样其实 zepto 内部再处理就没有问题了呀?所以是哪里给漏编码了呢?

哪里来的回车符

顺着上面两个问题,由于第一个问题的成本比较低,先看了下回车符哪里来的问题。我优先看了下当前地址,发现在当前地址的时候已经有问题了。而其他路径进入的这个页面都是正常的,只有这个特殊情况下有问题,遂反馈给服务端反查一下。最后服务端(PHP)那查到原来是因为他们读取文件时按行分割没有注意到方法里会带着换行的问题。

大概就是服务端那会有一个 token 文件,里面按行记录着一堆 ID,服务端会使用 file() 读取这个文件,然后将每个 ID 都 map 成一个地址下发下去。使用 file() 的好处是它在读取文件的时候能自动输出一个按行分割后的数组,这样就不需要额外操作。不过服务端同学没有注意,PHP 文档里也非常清楚的写明了:

Note:

Each line in the resulting array will include the line ending, unless FILE_IGNORE_NEW_LINES is used, so you still need to use rtrim() if you do not want the line ending present.

via: http://php.net/manual/zh/function.file.php

也就是说这种方法默认分割后的数组每个数据是包含最后的那个换行符的!想要去掉换行符需要添加 FILE_IGNORE_NEW_LINES 的标记参数。我自己也试了下发现果真如此!可以看到数组的前三个里面字符串的长度都是 4。

屏幕快照 2018-06-16 上午9.49.40.png

最后服务端从来源上解决了这个问题。

为什么没被转义

虽然问题解决了,但是我的第二个疑问其实还没有被解决。本来应该被转义的字符为什么没有被转义?是道德的沦丧还是人性的泯灭zepto 出问题了还是我们的代码里有什么潜在的风险?

我先去检查了一下 zepto 自己本身,发现它们的所有数据拼接都没有问题,使用了 $.param() 方法,而该方法内是使用了 escape() 对键值都做了编码的。zepto 出问题是不可能的了,那只能是我自己代码里的问题了。回到业务代码里查了一圈,最后发现,在某个阴暗的角落,居然窝藏了这么一段代码:

data.topicListApi = location.protocol + `//imnerd.org/detail?u=${uid}&sign=${sign}&n=10&tid=${tid}${onlineTypeParam}${tagParam}${rawUrlParam}${topUrlParam}`;

这一堆参数不经过任何编码就直接进行字符串拼接的操作…

后记

好啦,问题的来龙去脉前因后果总算是查清楚了。虽然这里面有各种坑,虽然服务端已经帮忙处理了,但是我明白最主要的问题还是最后的那个前段拼接的问题。所以我以血泪的历史告诉大家 URL 拼接一定要编码别搞什么骚操作啊!同时后面我 git blame 查了下写这个代码的同学,虽然离职了…但是我还是想…

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

6 评论

west Chrome76.0 iOS 12.3
2019-08-07 01:34:36 回复

果然跟我的情形很像,连标题都一样……

公子 Chrome76.0 Mac OS 10.14.5
2019-08-07 03:58:26 回复

@west 哈哈,是的,只是我的是包含换行符,你的是去掉了换行符,哈哈哈哈

纤纤 Chrome69.0 Mac OS 10.12.6
2018-10-30 02:45:41 回复

公子是个soul很有趣的人,喜欢你

公子 Chrome70.0 Mac OS 10.14.1
2018-10-31 12:23:02 回复

@纤纤 嘿嘿,只是偶尔皮一下,谢谢支持~~

minimalistrojan Safari11.1 Mac OS 10.13.6
2018-07-25 06:49:19 回复

「哪里来的回车符」那一部分,作者用的是什么编辑器啊?

公子 Chrome67.0 Mac OS 10.13.4
2018-07-25 09:07:55 回复

@minimalistrojan VSCode 的说~