BUG复盘分析:Typecho CommentFilter 插件的“换行符”血案与修复逻辑封面图

BUG复盘分析:Typecho CommentFilter 插件的“换行符”血案与修复逻辑

事情的起因,是我有群友跟我说我博客的评论区出问题了,所有提交评论都返回500报错。一开始我还不信,然后我自己试了一下,还真是!打开F12,顺着报错的提交找过去,发现返回的信息是:评论发布者的IP已被管理员屏蔽
我直接黑人问号,我还把我自己屏蔽了?回想了一下网站的技术栈,我感觉有一个插件嫌疑最大——CommentFilter
把过滤规则全部改成无动作后,评论恢复正常。小坏蛋,让我逮到了吧!

既然逮到了罪魁祸首,那就得扒开它的源码看看,到底是什么妖魔鬼怪在作祟。我顺藤摸瓜打开了 CommentFilterPlugin.php,深吸一口气,准备和 Bug 大战三百回合。结果你猜怎么着?这波啊,这波是“一个回车引发的血案”。

案发现场:留空即全量封杀

在原版本的插件中,检查屏蔽 IP 或屏蔽词汇时,使用的是简单的 \n 分割:

// 原版代码:解析并检查是否匹配
$words = explode("\n", $words_str);
if (empty($words)) {
    return false;
}
foreach ($words as $word) {
    if (false !== strpos($str, trim($word))) {
        return true;
    }
}

看似很正常的逻辑对吧?我们按照换行切分每一条规则,然后用 strpos 找一下子串。但致命的 Bug 就出在这个组合里:

  1. 如果你在输入框最后多输入了一个回车(或者干脆输入了两个回车),explode("\n", ...) 会切出一个空字符串 "" 作为数组元素。
  2. 接下来会执行 trim(""),结果依然是 ""(空字符串)。
  3. 接着进入终极深渊:strpos($str, "")

在 PHP 的底层逻辑里,查找一个空字符 "" 永远会在原字符串的最开头(索引 0)匹配成功!
由于 0 !== false 是恒成立的,这个判断语句直接返回了 true。这就意味着,只要配置文本里有一个空行,所有的 IP、所有的评论、所有的作者名都会立刻全量触发黑名单规则。好家伙,宁可错杀三千,不可放过一个是吧?我狠起来连站长本人都拉黑!这就造成了“换行血案”。

开始修复

找到了问题,接下来就要开始修复代码了,核心思路从“防破窗”变为了“严控输入法与匹配器”。

掐断万恶之源,重构规则解析器

为了防止这种“空行刺客”,我写了一个专属的 parse_words 方法,不仅兼容了 Windows/Mac/Linux 五花八门的换行符,还抛弃了所有空行:

private static function parse_words($words_str) {
    $lines = preg_split('/\r\n|\r|\n/', (string) $words_str);
    $words = array();
    foreach ($lines as $line) {
        $word = trim($line);
        if ($word !== '') { // 【画重点】空字符串?滚出我的数组!
            $words[] = $word;
        }
    }
    return $words;
}

IP 匹配逻辑优化

原版插件在匹配 IP 时同样由于滥用 strpos 导致了“模糊匹配过头”的问题(比如你屏蔽了 192.168.1.1,那么 192.168.1.10 会连坐遭殃,因为它是子串)。
修复版彻底重构了 check_ip 函数:

  • 彻底摒弃弱类型和子串匹配: 引入了 inet_pton(),将人类可读的 IP 地址转化为底层的二进制结构再进行精确比对,完全避免了子串污染。
  • 拥抱现代网络: 新增了对 IPv4 和 IPv6 CIDR 网段(如 /16, /24, /32)的无缝支持(通过 ip_in_cidr 算法),大大提升了插件对批量屏蔽恶意 IP 池的能力。

这下子,遇到恶意 IP 池,直接按网段封杀,爽!

提升系统的鲁棒性

原版中由于没有检查变量是否存在直接调用 $comment['mail']$comment['ip'],一旦前置插件将 $comment 处理掉了部分键值,便会引发大量的 Undefined Index 报错(特别是在 PHP 8.0+ 的严苛模式下)。修复版在开头增加了安全的初始化赋值:

$comment_ip = isset($comment['ip']) ? (string) $comment['ip'] : '';
$comment_text = isset($comment['text']) ? (string) $comment['text'] : '';

同时对于数据库空值判定也从 if(!$row['mail']) 换为了 if(empty($row['mail'])),让业务逻辑如丝般顺滑。

复盘与反思

经过这一通魔改,重新上传 Plugin.php,回到前台测试,那久违的“评论发布成功”提示终于弹出来了。
这次的“换行符血案”可以说是很多开发者(包括我自己)平时最容易忽略的盲区。永远不要信任用户的输入,哪怕那个“用户”是坐在后台敲键盘的你自己! 一个小小的空行,就能让整个博客的互动区全军覆没。这次不仅抓住了“小坏蛋”,还顺带强健了插件的体魄。以后再有垃圾评论敢来硬碰硬,我的 CIDR 封印术已经饥渴难耐了!

附录:完整代码展示

因为插件功能确实比较简单,也很好修复,我就不打算创建GitHub的仓库了。
这个章节单纯展示完整修改后的Plugin.php代码,在插件目录创建CommentFilter文件夹并把下面的内容保存为Plugin.php即可使用这个修复版的Typecho Comment Filter。

<?php
if (!defined('__TYPECHO_ROOT_DIR__')) exit;

/**
 * 评论过滤器 【<a href="https://www.nekopara.uk/archives/Fix_Typecho_Comment_Filter.html" target="_blank">Typecho Comment Filter</a>GTX690战术核显卡导弹 修复版】
 * * @package CommentFilter
 * @author jrotty,ghostry,Hanny,GTX690战术核显卡导弹
 * @version 1.2.2
 * @link https://www.nekopara.uk/archives/Fix_Typecho_Comment_Filter.html
 *
 * version 1.2.2 at 2026-07-17 修复换行导致全局屏蔽的Bug,增强IP匹配和CIDR支持,增强系统鲁棒性
 * * version 1.2.1 at 2020-06-27[typecho-fans合并2012-12-31 ghostry修改版]
 * 增加首次评论过滤,评论者可以在评论底部看到自己的未审核评论
 *
 * version 1.2.0 at 2017-10-10[非原作者更新修改,jrotty魔改更新]
 * 增加评论者昵称/超链接过滤功能
 *
 * 历史版本
 * version 1.1.0 at 2014-01-04
 * 增加机器评论过滤
 * version 1.0.2 at 2010-05-16
 * 修正发表评论成功后,评论内容Cookie不清空的Bug
 * version 1.0.1 at 2009-11-29
 * 增加IP段过滤功能
 * version 1.0.0 at 2009-11-14
 * 实现评论内容按屏蔽词过滤功能
 * 实现过滤非中文评论功能
 */
class CommentFilter_Plugin implements Typecho_Plugin_Interface
{
    /**
     * 激活插件方法,如果激活失败,直接抛出异常
     * * @access public
     * @return void
     * @throws Typecho_Plugin_Exception
     */
    public static function activate()
    {
        Typecho_Plugin::factory('Widget_Feedback')->comment = array('CommentFilter_Plugin', 'filter');
        Typecho_Plugin::factory('Widget_Archive')->header = array('CommentFilter_Plugin', 'add_filter_spam_input');
        return _t('评论过滤器启用成功,请配置需要过滤的内容');
    }

    /**
     * 禁用插件方法,如果禁用失败,直接抛出异常
     * * @static
     * @access public
     * @return void
     * @throws Typecho_Plugin_Exception
     */
    public static function deactivate(){}

    /**
     * 获取插件配置面板
     * * @access public
     * @param Typecho_Widget_Helper_Form $form 配置面板
     * @return void
     */
    public static function config(Typecho_Widget_Helper_Form $form)
    {
        $opt_spam = new Typecho_Widget_Helper_Form_Element_Radio('opt_spam', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "none",
                                                                 _t('屏蔽机器人评论'), "如果为机器人评论,将执行该操作。如果需要开启该过滤功能,请尝试进行评论测试,以免不同模板造成误判。");
        $form->addInput($opt_spam);

        $opt_ip = new Typecho_Widget_Helper_Form_Element_Radio('opt_ip', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "none",
                                                               _t('屏蔽IP操作'), "如果评论发布者的IP在屏蔽IP段,将执行该操作");
        $form->addInput($opt_ip);

        $words_ip = new Typecho_Widget_Helper_Form_Element_Textarea('words_ip', NULL, "0.0.0.0",
                                                                    _t('屏蔽IP'), _t('多条IP请用换行符隔开<br />支持星号格式,如:192.168.*.*<br />支持 IPv4/IPv6 CIDR,如:192.168.0.0/16、2001:db8::/32'));
        $form->addInput($words_ip);

        $opt_nocn = new Typecho_Widget_Helper_Form_Element_Radio('opt_nocn', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "none",
                                                                 _t('非中文评论操作'), "如果评论中不包含中文,则强行按该操作执行");
        $form->addInput($opt_nocn);

        $opt_nopl = new Typecho_Widget_Helper_Form_Element_Radio('opt_nopl', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "none",
                                                                 _t('首次评论操作'), "如果评论人没有评论过,则强行按该操作执行");
        $form->addInput($opt_nopl);

        $opt_ban = new Typecho_Widget_Helper_Form_Element_Radio('opt_ban', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "abandon",
                                                                _t('禁止词汇操作'), "如果评论中包含禁止词汇列表中的词汇,将执行该操作");
        $form->addInput($opt_ban);

        $words_ban = new Typecho_Widget_Helper_Form_Element_Textarea('words_ban', NULL, "fuck\n操你妈\n[url\n[/url]",
                                                                     _t('禁止词汇'), _t('多条词汇请用换行符隔开'));
        $form->addInput($words_ban);

        $opt_chk = new Typecho_Widget_Helper_Form_Element_Radio('opt_chk', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "waiting",
                                                                _t('敏感词汇操作'), "如果评论中包含敏感词汇列表中的词汇,将执行该操作");
        $form->addInput($opt_chk);

        $words_chk = new Typecho_Widget_Helper_Form_Element_Textarea('words_chk', NULL, "http://",
                                                                     _t('敏感词汇'), _t('多条词汇请用换行符隔开<br />注意:如果词汇同时出现于禁止词汇,则执行禁止词汇操作'));
        $form->addInput($words_chk);

        $opt_author = new Typecho_Widget_Helper_Form_Element_Radio('opt_author', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "spam",
                                                                   _t('关键昵称操作'), "如果评论中包含关键昵称词汇列表中的词汇,将执行该操作");
        $form->addInput($opt_author);

        $words_author = new Typecho_Widget_Helper_Form_Element_Textarea('words_author', NULL, "澳门银座\n自动化软件\n量化交易",
                                                                        _t('关键昵称词汇'), _t('多条词汇请用换行符隔开'));
        $form->addInput($words_author);

        $opt_url = new Typecho_Widget_Helper_Form_Element_Radio('opt_url', array("none" => "无动作", "waiting" => "标记为待审核", "spam" => "标记为垃圾", "abandon" => "评论失败"), "spam",
                                                                _t('垃圾链接过滤操作'), "如果评论中包含垃圾链接列表中字符串,将执行该操作");
        $form->addInput($opt_url);

        $words_url = new Typecho_Widget_Helper_Form_Element_Textarea('words_url', NULL, "www.vps521.cn",
                                                                     _t('垃圾链接'), _t('多条词汇请用换行符隔开,链接格式请参考上边输入框默认的链接'));
        $form->addInput($words_url);
    }

    /**
     * 个人用户的配置面板
     * * @access public
     * @param Typecho_Widget_Helper_Form $form
     * @return void
     */
    public static function personalConfig(Typecho_Widget_Helper_Form $form){}

    /**
     * 评论过滤器
     * */
    public static function filter($comment, $post)
    {
        $options = Typecho_Widget::widget('Widget_Options');
        $filter_set = $options->plugin('CommentFilter');
        $opt = "none";
        $error = "";
        $comment_ip = isset($comment['ip']) ? (string) $comment['ip'] : '';
        $comment_text = isset($comment['text']) ? (string) $comment['text'] : '';
        $comment_author = isset($comment['author']) ? (string) $comment['author'] : '';
        $comment_mail = isset($comment['mail']) ? (string) $comment['mail'] : '';
        $comment_url = isset($comment['url']) ? (string) $comment['url'] : '';

        //机器评论处理
        if ($opt == "none" && $filter_set->opt_spam != "none") {
            $spam_token = isset($_POST['filter_spam']) ? (string) $_POST['filter_spam'] : '';
            if ($spam_token != '48616E6E79') {
                $error = "请勿使用第三方工具进行评论";
                $opt = $filter_set->opt_spam;
            }
        }

        //屏蔽IP段处理
        if ($opt == "none" && $filter_set->opt_ip != "none") {
            if (CommentFilter_Plugin::check_ip($filter_set->words_ip, $comment_ip)) {
                $error = "评论发布者的IP已被管理员屏蔽";
                $opt = $filter_set->opt_ip;
            }
        }

        //纯中文评论处理
        if ($opt == "none" && $filter_set->opt_nocn != "none") {
            if (preg_match("/[\x{4e00}-\x{9fa5}]/u", $comment_text) == 0) {
                $error = "评论内容请不少于一个中文汉字";
                $opt = $filter_set->opt_nocn;
            }
        }

        //首次评论操作
        if($opt == "none" && $filter_set->opt_nopl != "none"){
            if($comment_mail){
                $db = Typecho_Db::get();
                $select=$db->select('mail')
                ->from('table.comments')
                ->where('mail = ?', $comment_mail);
                $result = $db->query($select);
                $row = $db->fetchRow($result);
                if(empty($row['mail'])){
                    $error = "您的邮箱为首次评论,需按规则执行对应操作";
                    $opt = $filter_set->opt_nopl;
                }
            }
        }

        //检查禁止词汇
        if ($opt == "none" && $filter_set->opt_ban != "none") {
            if (CommentFilter_Plugin::check_in($filter_set->words_ban, $comment_text)) {
                $error = "评论内容中包含禁止词汇";
                $opt = $filter_set->opt_ban;
            }
        }

        //检查敏感词汇
        if ($opt == "none" && $filter_set->opt_chk != "none") {
            if (CommentFilter_Plugin::check_in($filter_set->words_chk, $comment_text)) {
                $error = "评论内容中包含敏感词汇";
                $opt = $filter_set->opt_chk;
            }
        }

        //检查关键昵称词汇
        if ($opt == "none" && $filter_set->opt_author != "none") {
            if (CommentFilter_Plugin::check_in($filter_set->words_author, $comment_author)) {
                $error = "该类型昵称已被禁止评论";
                $opt = $filter_set->opt_author;
            }
        }

        //检查评论者链接
        if ($opt == "none" && $filter_set->opt_url != "none") {
            if (CommentFilter_Plugin::check_in($filter_set->words_url, $comment_url)) {
                $error = "该类型评论者超链接被禁止评论";
                $opt = $filter_set->opt_url;
            }
        }

        //执行操作
        if ($opt == "abandon") {
            Typecho_Cookie::set('__typecho_remember_text', $comment['text']);
            throw new Typecho_Widget_Exception($error);
        }
        else if ($opt == "spam") {
            $comment['status'] = 'spam';
        }
        else if ($opt == "waiting") {
            $comment['status'] = 'waiting';
        }
        $_SESSION['comment']=$comment;
        Typecho_Cookie::delete('__typecho_remember_text');
        return $comment;
    }

    /**
     * 将多行配置整理为非空规则列表
     *
     */
    private static function parse_words($words_str)
    {
        $lines = preg_split('/\r\n|\r|\n/', (string) $words_str);
        $words = array();
        foreach ($lines as $line) {
            $word = trim($line);
            if ($word !== '') {
                $words[] = $word;
            }
        }
        return $words;
    }

    /**
     * 检查$str中是否含有$words_str中的词汇
     * */
    private static function check_in($words_str, $str)
    {
        $words = self::parse_words($words_str);
        $str = (string) $str;
        foreach ($words as $word) {
            if (false !== strpos($str, $word)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 检查IP是否属于指定的IPv4或IPv6 CIDR网段
     *
     */
    private static function ip_in_cidr($ip, $cidr)
    {
        $parts = explode('/', (string) $cidr, 2);
        if (count($parts) !== 2) {
            return false;
        }

        $network = trim($parts[0]);
        $prefix = trim($parts[1]);
        if ($network === '' || $prefix === '' || !ctype_digit($prefix)) {
            return false;
        }

        $packed_ip = @inet_pton((string) $ip);
        $packed_network = @inet_pton($network);
        if ($packed_ip === false || $packed_network === false || strlen($packed_ip) !== strlen($packed_network)) {
            return false;
        }

        $prefix = (int) $prefix;
        $max_prefix = strlen($packed_ip) * 8;
        if ($prefix < 0 || $prefix > $max_prefix) {
            return false;
        }

        $whole_bytes = (int) floor($prefix / 8);
        if ($whole_bytes > 0 && substr($packed_ip, 0, $whole_bytes) !== substr($packed_network, 0, $whole_bytes)) {
            return false;
        }

        $remaining_bits = $prefix % 8;
        if ($remaining_bits === 0) {
            return true;
        }

        $mask = (0xff << (8 - $remaining_bits)) & 0xff;
        return (ord($packed_ip[$whole_bytes]) & $mask) === (ord($packed_network[$whole_bytes]) & $mask);
    }

    /**
     * 检查$ip中是否在$words_ip的IP段中
     * */
    private static function check_ip($words_ip, $ip)
    {
        $words = self::parse_words($words_ip);
        $ip = trim((string) $ip);
        if ($ip === '') {
            return false;
        }
        $packed_ip = @inet_pton($ip);
        foreach ($words as $word) {
            if (false !== strpos($word, '/')) {
                if (self::ip_in_cidr($ip, $word)) {
                    return true;
                }
            } else if (false !== strpos($word, '*')) {
                $pattern = '/^' . str_replace('\\*', '[0-9]{1,3}', preg_quote($word, '/')) . '$/';
                if (preg_match($pattern, $ip)) {
                    return true;
                }
            } else {
                $packed_word = @inet_pton($word);
                if (($packed_ip !== false && $packed_word !== false && $packed_ip === $packed_word) || $ip === $word) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 在表单中增加 filter_spam 隐藏域
     * */
    public static function add_filter_spam_input($header, $archive)
    {
        $options = Typecho_Widget::widget('Widget_Options');
        $filter_set = $options->plugin('CommentFilter');
        if ($filter_set->opt_spam != "none" && $archive->is('single') && $archive->allow('comment')) {
            echo '<script type="text/javascript">
            function get_form(input) {
            var node = input;
            while (node) {
                node = node.parentNode;
                if (node.nodeName.toLowerCase() == "form") {
                    return node;
        }
        }
        return null;
        };
        window.onload = function() {
        var inputs = document.getElementsByTagName("textarea");
        var i, input_author;
        input_author = null;
        for (i=0; i<inputs.length; i++) {
            if (inputs[i].name.toLowerCase() == "text") {
                input_author = inputs[i];
                break;
        }
        }
        var form_comment = get_form(input_author);
        if (form_comment) {
            var input_hd = document.createElement("input");
            input_hd.type = "hidden";
            input_hd.name = "filter_spam";
            input_hd.value = "48616E6E79";
            form_comment.appendChild(input_hd);
        } else {
            alert("find input author error!");
        }
        }
        </script>
        ';
        }
    }

}
评论区 (2)
  • ayiya 的头像
    ayiya 2026-08-01 08:54

    我最近呗纯英文骚扰坏了 然后弄个插件也出现你受的问题了,还好你解决了 愿你阳寿+1