故障排查报告

书籍在线阅读 HTTP 400 故障排查与修复

wxhappylife.top (Demain Library)  |  2026-06-28

一、问题现象

用户在书籍页面点击《地下室手记》卡片的「在线阅读」按钮后,阅读器页面显示红色错误信息:

错误信息

加载失败: 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-pE0N443 → 容器内 SSLSSL 终端 + 反向代理 + 静态文件
Halo Pro 2.24.21Panel-halo-ykDt30031 → 8090CMS 内容管理 + 附件存储
MySQL 8.41Panel-mysql-fSxj仅 127.0.0.1:3306数据库
1Panel WAF (1pwaf)内嵌于 OpenRestyLua 防火墙模块

请求链路

浏览器 fetch(bookUrl) │ ▼ OpenResty (:443 SSL) │ ├── location /upload/ ─── 无此规则(原始状态) │ └── location / ─── proxy_pass http://127.0.0.1:30031 │ │ │ ┌──────────────────────┘ ▼ 1pwaf WAF (access_by_lua_file) ◄── 在此拦截! │ ▼ Halo Pro (返回附件文件)

三、诊断过程

步骤 1:确认文件存在

首先检查 Halo 附件目录中文件是否真实存在:

ls -la /opt/1panel/apps/halo/halo/data/attachments/upload/地下室手记*

结果:文件存在,大小 354,532 字节。排除「文件缺失」的可能。

步骤 2:检查 Nginx 访问日志

从 Nginx access.log 中找到关键线索:

"GET /upload/地下室手记 ([俄] 陀思妥耶夫斯基) (z-library.sk, 1lib.sk, z-lib.sk).epub HTTP/2.0" 400 0

注意两个关键数据:

步骤 3:curl 对比测试

在服务器上用 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 请求和外部请求的处理不同

步骤 4:定位 WAF 规则

检查 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_filehttp 级别运行,意味着所有 server block 和 location block 的请求都会先经过 WAF 检查,无论目标路径是什么。

步骤 5:确认 WAF 拦截原因

分析浏览器请求 URL 中的特殊字符:

被拦截的 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 拦截。

为什么 curl 不受影响?

WAF 对来自 127.0.0.1(localhost)的请求有隐式放行策略,或者 curl 的 User-Agent / 请求头特征不触发某些规则。浏览器通过外网 HTTPS 访问时,完整的请求链路(HTTP/2 + 浏览器 UA + 外网 IP)会触发 WAF 的全部检查规则。

为什么其他图片正常?

封面图片文件名格式为 cover-书名.jpg,仅含中文、连字符和冒号,不含括号、方括号等触发 WAF 的特殊字符。

五、解决方案

解决方案采用三层架构,确保既解决当前问题,又对未来新上传的附件一劳永逸。

第一层: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 文件,报 500
  • access_by_lua_block {} — 空块不允许,Nginx 报配置错误
  • access_by_lua_block { return } — ✅ 正确写法,return 后直接放行

第二层:try_files + Halo 代理回退

静态文件存在时直接返回(最快),不存在时自动回退到 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;
    ...
}

第三层:rsync 定时同步

每 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/

六、修复后的请求流程

浏览器请求 /upload/xxx.epub │ ▼ OpenResty (:443 SSL) │ ▼ location /upload/ ── access_by_lua_block { return } ← WAF 已绕过 ✓ │ ▼ try_files $uri @halo_upload │ │ ├─ 文件存在 └─ 文件不存在 │ (静态目录) (新上传的) │ │ ▼ ▼ 直接返回 location @halo_upload (毫秒级) │ ▼ proxy_pass → Halo(:30031) │ ▼ 返回文件 ✓ │ rsync 5分钟后同步到静态目录 │ ▼ 后续请求走静态路径(更快)

七、验证结果

测试项修复前修复后
curl localhost HTTP 200 ✓ HTTP 200 ✓
浏览器 HTTPS(epub 含特殊字符) HTTP 400 ✗ HTTP 200 ✓
浏览器阅读器加载《地下室手记》 加载失败: HTTP 400 正常渲染 119 页
静态文件删除后回退到 Halo HTTP 200, 354532 bytes ✓
封面图片(无特殊字符) HTTP 200 ✓ HTTP 200 ✓

八、经验总结

排查方法论

  1. 对比法:curl 正常 vs 浏览器异常 → 缩小到 WAF / HTTP 版本 / 请求头差异
  2. 日志定位:Nginx access.log 的 400 状态码 + 0 字节响应 → 指向请求被中间件拦截
  3. 排除法:确认文件存在 → 排除 404;确认 curl 可达 → 排除 Halo 问题
  4. 差异分析:对比正常 URL(封面图)和异常 URL(EPUB)的字符组成 → 锁定特殊字符

关键技术点

OpenResty WAF 机制
  • access_by_lua_file 在 http 级别运行时,所有请求都会先经过 WAF,包括 location 块匹配的请求
  • location 块中用 access_by_lua_block { return } 可以覆盖(跳过)http 级别的 WAF
  • 不能用 access_by_lua_file off 来禁用 — 这是常见误区
Docker 跨容器文件访问
  • Docker bind mount 不支持跨容器的 symlink(容器内无法解析宿主机绝对路径)
  • 解决方案:文件拷贝 + rsync 定时同步,或添加 Docker volume mount(需要 1Panel 支持)
Nginx try_files + 命名 location
  • try_files $uri @name 可以先查静态文件,不存在时内部重定向到命名 location
  • 命名 location 的 access 阶段不会重新运行(沿用原 location 的 access 结果)
  • 这意味着 WAF bypass 对回退代理同样生效

九、修改的文件清单

文件路径修改内容
/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 的定时任务