用户在书籍页面点击《地下室手记》卡片的「在线阅读」按钮后,阅读器页面显示红色错误信息:
加载失败: HTTP 400
请求 URL:/upload/%E5%9C%B0%E4%B8%8B%E5%AE%A4%E6%89%8B%E8%AE%B0%20(%5B%E4%BF%84%5D%20...).epub
与此同时,其他类型的附件(如封面图片 /upload/cover-xxx.jpg)可以正常加载,问题仅出现在包含中文、括号、方括号等特殊字符的 EPUB/PDF 文件上。
站点基于 1Panel 面板管理,核心组件均为 Docker 容器部署:
| 组件 | 容器名 | 端口映射 | 职责 |
|---|---|---|---|
| OpenResty (Nginx) | 1Panel-openresty-pE0N | 443 → 容器内 SSL | SSL 终端 + 反向代理 + 静态文件 |
| Halo Pro 2.24.2 | 1Panel-halo-ykDt | 30031 → 8090 | CMS 内容管理 + 附件存储 |
| MySQL 8.4 | 1Panel-mysql-fSxj | 仅 127.0.0.1:3306 | 数据库 |
| 1Panel WAF (1pwaf) | 内嵌于 OpenResty | — | Lua 防火墙模块 |
首先检查 Halo 附件目录中文件是否真实存在:
ls -la /opt/1panel/apps/halo/halo/data/attachments/upload/地下室手记*
结果:文件存在,大小 354,532 字节。排除「文件缺失」的可能。
从 Nginx access.log 中找到关键线索:
"GET /upload/地下室手记 ([俄] 陀思妥耶夫斯基) (z-library.sk, 1lib.sk, z-lib.sk).epub HTTP/2.0" 400 0
注意两个关键数据:
在服务器上用 curl 请求同一个 URL:
curl -s -o /dev/null -w "HTTP %{http_code}" \
"http://127.0.0.1/upload/%E5%9C%B0%E4%B8%8B...epub"
# 结果:HTTP 200 ✓ (curl 正常返回)
curl 从 localhost 请求返回 200,但浏览器请求返回 400。这揭示了一个关键差异:WAF 对 localhost 请求和外部请求的处理不同。
检查 1Panel WAF 的加载方式和规则:
# WAF 在 http 级别加载(影响所有请求)
cat /opt/1panel/apps/openresty/openresty/1pwaf/data/conf/waf.conf
lua_shared_dict waf_req_count 10m;
access_by_lua_file /usr/local/openresty/1pwaf/waf.lua; ◄ 关键行
WAF 的 access_by_lua_file 在 http 级别运行,意味着所有 server block 和 location block 的请求都会先经过 WAF 检查,无论目标路径是什么。
分析浏览器请求 URL 中的特殊字符:
文件名包含以下字符组合,触发 WAF 的安全规则:
( ) — 圆括号(SQL 注入特征匹配)[ ] — 方括号(XSS / 注入特征匹配)%20 — 空格编码%25 — 百分号编码(双重编码嫌疑)对比正常加载的封面图片 URL:/upload/cover-思考:快与慢.jpg?width=400 — 仅包含中文和冒号,不含括号和方括号,所以不被 WAF 拦截。
1Panel WAF(1pwaf)在 OpenResty 的 http 级别运行 access_by_lua_file,对所有请求进行安全扫描。EPUB 文件名中含有括号 ()、方括号 [] 等字符,被 WAF 规则匹配为潜在攻击(SQL 注入 / XSS),直接返回 HTTP 400 拦截。
WAF 对来自 127.0.0.1(localhost)的请求有隐式放行策略,或者 curl 的 User-Agent / 请求头特征不触发某些规则。浏览器通过外网 HTTPS 访问时,完整的请求链路(HTTP/2 + 浏览器 UA + 外网 IP)会触发 WAF 的全部检查规则。
封面图片文件名格式为 cover-书名.jpg,仅含中文、连字符和冒号,不含括号、方括号等触发 WAF 的特殊字符。
解决方案采用三层架构,确保既解决当前问题,又对未来新上传的附件一劳永逸。
在 Nginx 配置中为 /upload/ 路径添加专用 location 块,用空的 Lua access 阶段绕过 WAF:
location /upload/ {
access_by_lua_block { return } # 绕过 1pwaf WAF
alias /usr/share/nginx/html/upload/;
...
}
access_by_lua_file off; — 被解释为加载名为 "off" 的 Lua 文件,报 500access_by_lua_block {} — 空块不允许,Nginx 报配置错误access_by_lua_block { return } — ✅ 正确写法,return 后直接放行静态文件存在时直接返回(最快),不存在时自动回退到 Halo 代理(保证新上传文件立即可用):
location /upload/ {
access_by_lua_block { return }
alias /usr/share/nginx/html/upload/;
try_files $uri @halo_upload; # 先查静态 → 回退代理
types {
application/epub+zip epub;
application/pdf pdf;
...
}
}
# 命名 location:静态文件不存在时代理到 Halo
location @halo_upload {
access_by_lua_block { return }
proxy_pass http://127.0.0.1:30031;
proxy_set_header Host $host;
...
}
每 5 分钟自动将 Halo 新上传的附件同步到 OpenResty 静态目录,确保后续请求走静态路径(更快):
# /opt/1panel/apps/openresty/openresty/sync-uploads.sh rsync -au --no-links \ /opt/1panel/apps/halo/halo/data/attachments/upload/ \ /opt/1panel/apps/openresty/openresty/root/upload/ # crontab: */5 * * * * /opt/.../sync-uploads.sh
由于 OpenResty 和 Halo 运行在不同的 Docker 容器中,Docker 挂载不支持跨容器的 symlink(容器内无法解析宿主机的绝对路径),因此采用文件拷贝方案将 Halo 附件目录复制到 OpenResty 容器可访问的路径:
cp -r /opt/1panel/apps/halo/halo/data/attachments/upload/ \
/opt/1panel/apps/openresty/openresty/root/upload/
| 测试项 | 修复前 | 修复后 |
|---|---|---|
| curl localhost | HTTP 200 ✓ | HTTP 200 ✓ |
| 浏览器 HTTPS(epub 含特殊字符) | HTTP 400 ✗ | HTTP 200 ✓ |
| 浏览器阅读器加载《地下室手记》 | 加载失败: HTTP 400 | 正常渲染 119 页 |
| 静态文件删除后回退到 Halo | — | HTTP 200, 354532 bytes ✓ |
| 封面图片(无特殊字符) | HTTP 200 ✓ | HTTP 200 ✓ |
access_by_lua_file 在 http 级别运行时,所有请求都会先经过 WAF,包括 location 块匹配的请求access_by_lua_block { return } 可以覆盖(跳过)http 级别的 WAFaccess_by_lua_file off 来禁用 — 这是常见误区try_files $uri @name 可以先查静态文件,不存在时内部重定向到命名 location| 文件路径 | 修改内容 |
|---|---|
/opt/1panel/apps/openresty/openresty/conf/conf.d/wxhappylife.conf |
新增 location /upload/ 和 location @halo_upload |
/opt/1panel/apps/openresty/openresty/sync-uploads.sh |
新建 rsync 同步脚本 |
/opt/1panel/apps/openresty/openresty/root/upload/ |
新建目录,从 Halo 附件目录拷贝 240 个文件(258MB) |
| crontab | 新增每 5 分钟运行 sync-uploads.sh 的定时任务 |