事情的起因,是我有群友跟我说我博客的评论区出问题了,所有提交评论都返回500报错。一开始我还不信,然后我自己试了一下,还真是!打开F12,顺着报错的提交找过去,发现返回的信息是:评论发布者的IP已被管理员屏蔽
我直接黑人问号,我还把我自己屏蔽了?回想了一下网站的技术栈,我感觉有一个插件嫌疑最大——CommentFilter
把过滤规则全部改成无动作后,评论恢复正常。小坏蛋,让我逮到了吧!
既然逮到了罪魁祸首,那就得扒开它的源码看看,到底是什么妖魔鬼怪在作祟。我顺藤摸瓜打开了 CommentFilter 的 Plugin.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 就出在这个组合里:
- 如果你在输入框最后多输入了一个回车(或者干脆输入了两个回车),
explode("\n", ...)会切出一个空字符串""作为数组元素。 - 接下来会执行
trim(""),结果依然是""(空字符串)。 - 接着进入终极深渊:
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>
';
}
}
}
我最近呗纯英文骚扰坏了 然后弄个插件也出现你受的问题了,还好你解决了 愿你阳寿+1
能帮到你就好,现在很多Typecho的插件作者都不维护了,只能自己下场维护了