慕雪的小助手正在绞尽脑汁···
慕雪小助手的总结
DeepSeek & LongCat
本文部分内容由 AI 辅助生成,请结合实际情况判断参考。

使用OpenResty读取Artalk评论昵称,命中广告关键词就返回403。

1.起因

之前我的Artalk评论区总会收到一些脚本发出来的垃圾评论,所以在Nginx里面加了一层User-Agent过滤,把curlpython requests之类的请求直接拦掉了。

之前的文章:【Nginx】配置nginx拦截非浏览器请求

这个配置确实清静了一段时间,但也只能拦住那些演都不演的脚本。User-Agent本来就是请求方自己填的,脚本随便伪装成Chrome或者Edge,就能和普通浏览器一样进入Artalk。

最近评论区又冒出来一些奇怪玩意,昵称直接叫“算命”“机场”“手游代理”,评论内容则是随便敲几个数字,再把广告链接塞到网址里面。好家伙,这是把昵称当广告牌用了。

既然拦请求头已经不够了,那就继续往里面检查:只要提交评论时,昵称包含指定的广告词,OpenResty直接返回403,不再把请求交给Artalk。

2.为什么要用OpenResty Lua

我目前用的Artalk服务端版本应该还是2.5.4左右,创建评论使用的是下面这个接口:

1
POST /api/v2/comments

提交的数据是JSON,其中昵称字段叫name

1
2
3
4
5
6
7
{
"name": "普通访客",
"email": "me@example.com",
"content": "评论内容",
"page_key": "/posts/1234567890",
"site_name": "慕雪的博客"
}

Artalk API文档:https://artalk.js.org/http-api

一开始我想直接用Nginx的$request_body配合if匹配,后来发现这玩意在当前阶段不一定已经读到请求体,写出来看着很合理,实际上可能根本没生效。

我的反向代理跑的本来就是OpenResty,它自带lua-nginx-module,可以在访问阶段调用ngx.req.read_body(),把JSON解析出来以后再检查name。既然现成的工具已经摆在这里了,那就别继续为难原生Nginx配置了。

OpenResty Lua模块:https://github.com/openresty/lua-nginx-module

还有一点很重要:这里只检查昵称,不检查整个请求体。否则正常读者在评论正文里面聊到“机场”“代理”之类的词,也会被一棒子打成广告,这误伤就太离谱了。

3.准备外置屏蔽词

3.1 创建词库文件

一开始我把词全部写在access_by_lua_block里面,测试“算命”的确可以返回403。不过每次加词都要改一大段Nginx配置,看着就头疼,所以后来把它拆成了单独的文本文件。

在OpenResty的conf.d目录创建:

1
artalk-blocked-words.txt

文件一行放一个词,以#开头的行会被当成注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# 命理广告
算命
八字
生辰八字
看相
面相
手相
命理
风水
占卜
起名
测名
改运
转运
开运
姻缘测算
合婚

# 网络代理广告
机场
机场推荐
机场订阅
梯子
翻墙
科学上网
节点
节点购买
节点订阅
代理
代理订阅
手游代理
加速器
专线节点
中转节点
vpn
ssr节点
v2ray节点
trojan节点
clash订阅

# 博彩广告
博彩
赌博
赌场
真人娱乐
真人荷官
棋牌游戏
彩票
时时彩
北京赛车
六合彩
体育投注
外围投注
投注平台
送彩金
包赢
稳赚不赔

# 色情和交友广告
约炮
同城约炮
一夜情
上门服务
成人网站
成人直播
成人视频
裸聊
色情直播
私密视频

# 诈骗和导流广告
刷单
兼职刷单
日赚
躺赚
网赚
赚钱项目
高佣返利
资金盘
杀猪盘
跑分
网贷
快速下款
征信修复
信用卡套现
投资导师
带单老师
牛股推荐
加微信
微信咨询
联系微信
扫码咨询
进群领取
telegram

因为我的conf.d目录本身就挂载到了宿主机,所以这个文件也会跟着持久化。不要只在容器里面临时创建文件,否则以后重建容器,词库也会一起消失!!!!

3.2 完整location配置

下面就是我最后使用的完整location配置,Artalk在本机的代理端口是14722。如果你的端口不一样,记得修改proxy_pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
location ^~ / {
###### 非浏览器请求拦截 ######

set $block_request 0;

if ($http_user_agent ~* (curl|wget|httpie|python|Go-http-client|okhttp|java|bot|spider|crawler|Postman|Apache-HttpClient|HeadlessChrome|xx032_)) {
set $block_request 1;
}

if ($http_user_agent ~* "UptimeRobot") {
set $block_request 0;
}

if ($block_request = 1) {
return 403;
}

###### 非浏览器请求拦截 ######


###### Artalk评论昵称关键字拦截 ######

access_by_lua_block {
-- 返回JSON错误
local function respond_error(status, message)
ngx.status = status
ngx.header["Content-Type"] =
"application/json; charset=utf-8"

-- 跨域部署时允许Artalk前端读取错误信息
local origin = ngx.var.http_origin

if origin and origin ~= "" then
ngx.header["Access-Control-Allow-Origin"] = origin
ngx.header["Access-Control-Allow-Credentials"] = "true"
ngx.header["Vary"] = "Origin"
end

ngx.say('{"msg":"' .. message .. '"}')
return ngx.exit(status)
end

-- 只处理Artalk评论提交请求
if ngx.req.get_method() ~= "POST"
or (
ngx.var.uri ~= "/api/v2/comments"
and ngx.var.uri ~= "/api/v2/comments/"
) then
return
end

ngx.req.read_body()

local body = ngx.req.get_body_data()

-- 请求体较大时,Nginx可能将其写入临时文件
if not body then
local body_file = ngx.req.get_body_file()

if body_file then
local request_file, request_file_err =
io.open(body_file, "rb")

if request_file then
body = request_file:read("*a")
request_file:close()
else
ngx.log(
ngx.ERR,
"Failed to read Artalk request body file: ",
request_file_err or "unknown error",
", path: ",
body_file
)
end
end
end

if not body then
ngx.log(
ngx.ERR,
"Failed to read Artalk request body"
)

return respond_error(
ngx.HTTP_INTERNAL_SERVER_ERROR,
"Comment filtering is temporarily unavailable. Please try again later."
)
end

local cjson = require "cjson.safe"
local data, json_err = cjson.decode(body)

if type(data) ~= "table" then
ngx.log(
ngx.WARN,
"Failed to parse Artalk request JSON: ",
json_err or "unknown error"
)
return
end

if type(data.name) ~= "string" then
return
end

-- 统一处理昵称和屏蔽词
local function normalize(value)
value = value:lower()
value = value:gsub("%s+", "")
value = value:gsub("[%.%-%_|]+", "")
value = value:gsub("·", "")
value = value:gsub("|", "")
return value
end

local name = normalize(data.name)

-- 尝试从常见OpenResty配置目录读取词库
local prefix = ngx.config.prefix()
local separator = prefix:sub(-1) == "/" and "" or "/"

local candidate_paths = {
prefix
.. separator
.. "conf.d/artalk-blocked-words.txt",

"/etc/nginx/conf.d/artalk-blocked-words.txt",

"/usr/local/openresty/nginx/conf.d/artalk-blocked-words.txt",

"/usr/local/openresty/nginx/conf/conf.d/artalk-blocked-words.txt"
}

local words_file = nil
local words_path = nil
local words_file_err = nil

for _, candidate_path in ipairs(candidate_paths) do
local candidate_file, candidate_err =
io.open(candidate_path, "r")

if candidate_file then
words_file = candidate_file
words_path = candidate_path
break
end

words_file_err = candidate_err
end

-- 读取失败时拒绝评论,防止过滤器静默失效
if not words_file then
ngx.log(
ngx.ERR,
"Failed to open Artalk blocked words file: ",
words_file_err or "unknown error",
", attempted paths: ",
table.concat(candidate_paths, ", ")
)

return respond_error(
ngx.HTTP_INTERNAL_SERVER_ERROR,
"Comment filtering is temporarily unavailable. Please try again later."
)
end

local blocked_word = nil
local loaded_word_count = 0

for line in words_file:lines() do
-- 去除UTF-8 BOM
line = line:gsub("^\239\187\191", "")

-- 去除行首和行尾空白
local word = line:match("^%s*(.-)%s*$")

-- 忽略空行和注释行
if word ~= "" and word:sub(1, 1) ~= "#" then
word = normalize(word)

if word ~= "" then
loaded_word_count = loaded_word_count + 1

if name:find(word, 1, true) then
blocked_word = word
break
end
end
end
end

words_file:close()

-- 词库为空时拒绝评论,防止过滤器静默失效
if loaded_word_count == 0 then
ngx.log(
ngx.ERR,
"Artalk blocked words file is empty: ",
words_path
)

return respond_error(
ngx.HTTP_INTERNAL_SERVER_ERROR,
"Comment filtering is temporarily unavailable. Please try again later."
)
end

if blocked_word then
ngx.log(
ngx.WARN,
"Blocked Artalk spam nickname from ",
ngx.var.remote_addr,
", matched word: ",
blocked_word
)

return respond_error(
ngx.HTTP_FORBIDDEN,
"Comment rejected: the nickname contains prohibited advertising or spam-related terms. Please use a different nickname."
)
end
}

###### Artalk评论昵称关键字拦截 ######


proxy_pass http://127.0.0.1:14722;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-Proto $scheme;

proxy_http_version 1.1;

add_header X-Cache $upstream_cache_status;
add_header Strict-Transport-Security "max-age=31536000";
add_header Cache-Control no-cache;
}

这里没有把我服务器上的地区IP限制配置放进去,毕竟每个人的访问控制都不一样。保留自己原本的includeallowdeny配置就行。

4.我又把自己坑了一次

内置数组测试成功以后,我兴冲冲地把词库换成了外部文件,然后用“手游代理”当昵称发评论。

结果,它居然成功发出去了。

这就很离谱,因为“手游代理”不光应该命中完整词语,还会命中词库里的“代理”。后来才发现,不是匹配代码有问题,而是OpenResty根本没有读到外部文件。

最开始的代码在io.open()失败后直接return,相当于过滤器出错就放行。文件放错目录、没有挂载进容器、权限不对,都会让整套过滤静悄悄地失效,日志不看根本发现不了。

所以最终配置做了两件事:

  1. 依次尝试几个常见的OpenResty配置路径;
  2. 词库不存在、不可读或者为空时返回500,不再把评论放过去。

这样配置有问题时,前端会明确显示:

1
Comment filtering is temporarily unavailable. Please try again later.

虽然会暂时影响评论,但总比自以为过滤器在工作,实际广告全放进来强。对我这种个人博客来说,过滤器挂了就先禁止提交反而更加合适。

5.检查和测试

5.1 检查并重载OpenResty

修改Nginx配置以后,先检查语法:

1
$ docker exec 你的OpenResty容器名 openresty -t

没有问题再重载:

1
$ docker exec 你的OpenResty容器名 openresty -s reload

如果镜像里面没有openresty命令,就把它换成nginx

只修改artalk-blocked-words.txt不需要重载,因为每次提交评论都会重新读取这个小文件。我的博客评论量不高,这点磁盘读取开销可以忽略。流量特别大的站点可以改成Lua模块配合require缓存,但更新词库以后就需要重载worker了。

5.2 模拟广告昵称

我原来的配置会拦截curl这个User-Agent,所以测试时要伪装成普通浏览器,不然拿到的403可能来自第一层UA规则,根本证明不了昵称过滤生效。

1
2
3
4
$ curl -i "https://你的Artalk域名/api/v2/comments" \
-A "Mozilla/5.0" \
-H "Content-Type: application/json" \
--data '{"name":"手游代理"}'

正常情况下会返回:

1
2
HTTP/2 403
{"msg":"Comment rejected: the nickname contains prohibited advertising or spam-related terms. Please use a different nickname."}

然后再换一个正常昵称:

1
2
3
4
$ curl -i "https://你的Artalk域名/api/v2/comments" \
-A "Mozilla/5.0" \
-H "Content-Type: application/json" \
--data '{"name":"普通访客"}'

因为测试数据缺少邮箱、正文和页面信息,Artalk大概率会返回400,但它不应该返回昵称过滤器产生的403。

最终回到网页上测试,“算命”“手游代理”这类昵称都会显示评论失败,普通昵称则可以正常发表评论,这下才算是真的搞定了。

6.还有几个要注意的地方

关键词过滤不是万能的,对方在两个字中间插入空格、横线、点号就可以尝试绕过。所以配置里面会先清理常见空白和分隔符,让“算 命”“机-场”“手_游_代_理”也能命中。

但是过滤做得越狠,误伤也会越多。尤其是“代理”“节点”“加速器”这种正常语境也会出现的词,我这里只过滤昵称,没有过滤评论正文,影响相对小一点。如果拿同一份词库过滤正文,最好先删掉这些宽泛词。

另外,User-Agent过滤、昵称关键词和IP限制都只能挡住一部分垃圾请求。Artalk自己的验证码、评论审核和反垃圾配置也该开还是得开,多放几层总比只靠一个正则表达式靠谱。

The end

从最开始拦python requests,到后来检查Artalk昵称,再到外置词库没加载导致广告漏网,这个小配置居然也能连续踩好几个坑。

不过现在以后再看到新的广告昵称,只要往文本文件里面加一行就完事了,终于不用每次都去改那一大坨Nginx配置。

有新的垃圾评论套路,也欢迎来评论区交流,前提是别把昵称起成“算命大师”。