背景

看着博客好久没人留言评论,今天心血来潮怀疑是不是我的博客评论系统坏了,然后随便给自己的一篇文章回复,发现竟然点击提交后竟然直接页面刷新,写的评论内容全部消失了...果然是坏了...

不过奇怪的是在把文章页面刷新后再评论就一切正常了...然后今天就花了一整天时间来找这个bug,所幸最终还是找到并成功解决了这个bug,问题出在Ajax请求的参数上。趁着自己还没有忘记,赶紧记录下来。

问题描述

博客是基于Typecho的,但主题是自己写的,为了体现出水平(作死),全站绝大部分内容都是采用Ajax加载。

症状就是:

  1. 如果是从博客中的各种链接点击进入文章页面的话,直接评论点击提交,得到的结果就是页面刷新了一次,然后评论的内容完全消失,就像从来没发生过;
  2. 在刷新后的文章页面上,评论点击提交,一切工作正常,页面刷新并显示刚才评论的内容;
  3. 从博客中的各种链接点击进入文章页面,并手动刷新一次再评论,会和2一样一切正常;
  4. 直接通过网址进入文章页面,评论一切正常。

原因分析

这里就简单点说了,实际是我花了一天才找到问题在哪。

怀疑点当然是在Ajax上了,但一般来说评论不成功Typecho都会返回一个页面解释原因的,但现在的情况是完全没有任何反馈直接刷新页面。开始手动慢慢找,首先从评论失败和评论成功两种文章页面的差异上下手,在Typecho设置->评论设置中关掉了开启反垃圾保护后终于发现了不同。

两个页面在评论表单中

<form method="post"
action="http://www.polarxiong.com/archives/Python-%E5%AE%9E%E7%8E%B0%E5%85%A8%E6%8E%92%E5%88%97.html/comment?_=d59336438aae02e5ab577dfc7f147bf3"
id="comment-form" role="form" class="form-horizontal">

后面的d59336438aae02e5ab577dfc7f147bf3这个串的值不一样,怀疑就是因为Ajax加载的文章这个串值是错的,导致提交评论时直接被丢弃。为了验证猜想开始扒源码。

主题中comments.php这部分的源码是

<form method="post" action="<?php $this->commentUrl() ?>"
id="comment-form" role="form" class="form-horizontal">

$this->commentUrl()在Typecho的Widget/Archive.php

protected function ___commentUrl()
{
    /** 生成反馈地址 */
    /** 评论 */
    $commentUrl = parent::___commentUrl();

    //不依赖js的父级评论
    $reply = $this->request->filter('int')->replyTo;
    if ($reply && $this->is('single')) {
        $commentUrl .= '?parent=' . $reply;
    }

    return $this->options->commentsAntiSpam ? $commentUrl : $this->security->getTokenUrl($commentUrl);
}

commentsAntiSpam就是反垃圾评论了,关了这个开关执行的就是getTokenUrl($commentUrl),看来那个串值就叫token。

$this->security->getTokenUrl()在Typecho的Widget/security.php

public function getTokenUrl($path)
{
    $parts = parse_url($path);
    $params = array();
    if (!empty($parts['query'])) {
        parse_str($parts['query'], $params);
    }
    $params['_'] = $this->getToken($this->request->getRequestUrl());
    $parts['query'] = http_build_query($params);
    return Typecho_Common::buildUrl($parts);
}

public function getToken($suffix)
{
    return md5($this->_token . '&' . $suffix);
}

果然就是这加的_这个参数,getToken()似乎就是一个加盐的MD5加密,所以现在来关注getRequestUrl()

$this->request->getRequestUrl()在Typecho的Typecho/Request.php

public function getRequestUrl()
{
    return self::getUrlPrefix() . $this->getRequestUri();
}
public function getRequestUri()
{
    if (!empty($this->_requestUri)) {
        return $this->_requestUri;
    }
}

看来问题就在_requestUri上了,把两个文章页面上的这个值打印出来看看

http://www.polarxiong.com/archives/Python-%E5%AE%9E%E7%8E%B0%E5%85%A8%E6%8E%92%E5%88%97.html?load_type=ajax
http://www.polarxiong.com/archives/Python-%E5%AE%9E%E7%8E%B0%E5%85%A8%E6%8E%92%E5%88%97.html

出错的页面后面多了个?load_type=ajax

先别着急得出结论,看看为啥这样会造成页面刷新。因为URI不同,所以MD5加密得到的token也不同。看看Typecho怎么处理评论的。

处理评论是在Typecho的Widget/Feedback.php

private function comment()
{
    // 使用安全模块保护
    $this->security->protect();
    ...
}

看到对评论首先就用protect()做一次验证,看看这个是在干啥。

$this->security->protect()在Typecho的Widget/security.php

public function protect()
{
    if ($this->request->get('_') != $this->getToken($this->request->getReferer())) {
        $this->response->goBack();
    }
}

找到出错的地方啦!这里把评论POST请求的_参数(即token)与请求的Referer(HTTP请求中的参数)比较,如果不一致就返回请求页面,效果就是页面刷新啦!通过HTTP抓包可以看到Referer就是发送POST的页面,即文章的URI;与_参数的区别就在于没有?load_type=ajax这部分了,这就是原因了。这个protect()就是防止恶意请求的安全模块,检查请求页面是否一致。

那么?load_type=ajax是从哪来的呢?相信熟悉Ajax的都比较熟悉:为了能够让服务器端知道请求是Ajax请求,在客户端发送Ajax GET请求时添加一个参数用来指示请求类型。这是一个常用的方法,但在Typecho这就因为URI不一致被安全模块挡回去了。

看看客户端发送Ajax请求

$.ajax({
    type:'get',
    url:$('#logo').attr('href') + "page/" + current_page+"/",
    data:{'load_type':'ajax'},
    success:function(msg){
        ...
    }
});

其中data就是GET请求参数,手动加上了load_type

再看看服务器端处理

<?php
    if(isset($_GET['load_type']) and $_GET['load_type'] == 'ajax'){
        ...
    }
?>

验证GET请求参数,判断是否为Ajax。怎么样,是不是很常见?

解决方法

通过上面的分析,原因一目了然啦,就是自己添加GET参数使得请求被安全模块过滤掉了。现在有两个选择,一是修改Typecho代码,绕过安全模块(显然只要把protect()那行注释掉就行了);另一个就是换用其他的服务器端Ajax识别机制,不要通过GET参数了。方法一肯定不能选了,那是拣了芝麻丢了西瓜,尝试方法二。

运气很好,很快就找到了一种新的方法,比之前通过GET参数不知道高到哪去了。对于Ajax请求,绝大部分客户端框架在发送这种请求时,都会发送HTTP_X_REQUESTED_WITH这个HTTP头,并且值为XMLHttpRequest。这样,客户端不需要对Ajax请求作任何标记,只需要服务器端验证HTTP_X_REQUESTED_WITH就能判断是否是Ajax请求了。

另一个发现就是Typecho实际上已经提供了这个API。在Typeco/Request.php

public function isAjax()
{
    return 'XMLHttpRequest' == $this->getServer('HTTP_X_REQUESTED_WITH');
}

isAjax()用来判断此请求是不是Ajax请求。

所以只需要将客户端Ajax请求GET参数去掉

$.ajax({
    type:'get',
    url:$('#logo').attr('href') + "page/" + current_page+"/",
    success:function(msg){
        ...
    }
});

然后服务器端验证修改为
request->isAjax()){ ... } ?>

怎么样?是不是健壮了许多?

小结

鉴于现在越来越多的框架采用了如Typecho的安全验证手段,在我们DIY Ajax请求时也要注意这方面潜在的问题。

潜在问题

没错,使用HTTP_X_REQUESTED_WITH也有潜在问题,不是所有的框架都支持Ajax请求设置HTTP_X_REQUESTED_WITH,比如早期版本的jQuery和Dojo。不过这个问题也容易解决,自己在Ajax请求构造HTTP_X_REQUESTED_WITH头就好了,只是需要注意这点。

参考