feat(autoban): 封禁响应图改为 ban_image_path 配置,默认仍是纯文字

上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、
由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。

改为配置驱动:
- 新增 [security] ban_image_path(相对路径以进程工作目录为基准);
- 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字
  "403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为
  一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn);
- 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。

安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。
把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件
原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。

顺带删掉 internal/route/assets(webp + embed),已无引用。

测试:
- TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文;
- TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、
  Content-Length 与文件一致、body 与文件逐字节相同;
- TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误
  都不得装载,且**不得把文件内容当响应体吐出去**;
- TestLoadBanImageTooLarge:超 2 MB 拒绝;
- TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。
变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去:
"[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。

实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
This commit is contained in:
tamakyi
2026-10-23 15:25:07 +08:00
parent 6fad0cbe50
commit 86ea96e5ca
10 changed files with 298 additions and 70 deletions
+104 -14
View File
@@ -2,33 +2,123 @@ package route
import (
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/tamakyi/TamaBox/internal/route/assets"
"github.com/sirupsen/logrus"
)
// banText 是没有任何封禁图时的响应体 —— 改造前的老行为。
const banText = "403 Forbidden"
// maxBanImageBytes 限制封禁图的大小。
//
// 不是为了防攻击者(路径来自运维配置),而是防手滑:被封的来源会反复重试,
// 每张图都会照常占用出网带宽,几百 KB 的原图能把这条路径变成放大器。
const maxBanImageBytes = 2 << 20
// banResponse 是装载好的封禁响应(图片体 + 内容类型)。
type banResponse struct {
body []byte
contentType string
}
// bannedImage 在启动时装载一次,之后每个被封请求只读。
// nil 表示没有可用的图,回退纯文字。
var bannedImage atomic.Pointer[banResponse]
// loadBanImage 读取 [security] ban_image_path 指向的图片。
//
// 任何失败都不返回 error、不影响启动:封禁响应是最后一道兜底,
// 不能因为一张图缺失就变成 500,更不能把站点拦在启动阶段。
// 失败时 bannedImage 保持 nil,writeBanned 会回退成纯文字 "403 Forbidden"。
func loadBanImage(path string) bool {
// 先清空:重复调用(测试、将来的热替换)时不至于留着上一次的图。
bannedImage.Store(nil)
path = strings.TrimSpace(path)
if path == "" {
return false
}
info, err := os.Stat(path)
if err != nil {
logrus.WithError(err).WithField("path", path).
Warn("ban_image_path 不可读,封禁响应回退为纯文字 403")
return false
}
if info.IsDir() {
logrus.WithField("path", path).
Warn("ban_image_path 是个目录,封禁响应回退为纯文字 403")
return false
}
if info.Size() > maxBanImageBytes {
logrus.WithFields(logrus.Fields{"path": path, "size": info.Size(), "max": maxBanImageBytes}).
Warn("ban_image_path 超过 2 MB(被封来源会反复重试,大图等于放大出网带宽),已回退为纯文字 403")
return false
}
body, err := os.ReadFile(path)
if err != nil {
logrus.WithError(err).WithField("path", path).
Warn("ban_image_path 读取失败,封禁响应回退为纯文字 403")
return false
}
// 按内容嗅探而不是按扩展名:配成 app.ini / .env 这类文件时必须拒绝,
// 否则等于把配置文件原样吐给每一个被封的攻击者。
// DetectContentType 只看前 512 字节,对常见图片格式足够。
ct := http.DetectContentType(body)
if !strings.HasPrefix(ct, "image/") {
logrus.WithFields(logrus.Fields{"path": path, "detected": ct}).
Warn("ban_image_path 不是图片(只接受 image/*:png/jpeg/gif/webp),已回退为纯文字 403")
return false
}
bannedImage.Store(&banResponse{body: body, contentType: ct})
logrus.WithFields(logrus.Fields{"path": path, "type": ct, "size": len(body)}).
Info("封禁响应图已装载")
return true
}
// writeBanned 写出「来源已被封禁」的响应。
//
// 状态码仍然是 403 —— 语义不变,只把 body 从 13 字节的 "403 Forbidden"
// 换成一张 256×256 的 webp(约 8.6 KB):
// - 顶掉明文:爆破/扫描脚本不少会读响应体里的 "403 Forbidden" 来确认自己
// 已被拦,换掉 body 至少让这类判断失效(状态码本身还是照常返回);
// - 给人看:误封(比如共享出口、代理)时打开浏览器看到一张写着 403 的图,
// 比一个没有任何样式的白屏好判断得多。
// 状态码始终是 403,只决定 body 是什么:
// - 配了合法图片 → 直接回图片(顶掉明文,爆破脚本靠读 "403 Forbidden"
// 确认自己被拦的那套判断会失效;人肉访问时也能看懂发生了什么);
// - 没配 / 配错 → 纯文字 "403 Forbidden",即改造前的老行为。
//
// 图片是编译期 embed 的,不走磁盘 IO,也不占一个可公开访问的 URL。
// 被封住的来源往往反复重试,所以这里显式给 Content-Length(否则 8 KB 会退回
// chunked),并标 no-store:中间层缓存一张 403 图没有意义,还会让解封后
// 的响应继续命中缓存。
// 两条分支都显式给 Content-Length:图片动辄几十 KB,超过 Go 的 2 KB 写缓冲,
// 不设就会退回 chunked。no-store 是因为中间层缓存一张 403 没有意义,
// 还会让解封后的响应继续命中缓存。
func writeBanned(w http.ResponseWriter, method string) {
img := bannedImage.Load()
if img == nil {
writeBannedText(w, method)
return
}
h := w.Header()
h.Set("Content-Type", "image/webp")
h.Set("Content-Length", strconv.Itoa(len(assets.BannedImage)))
h.Set("Content-Type", img.contentType)
h.Set("Content-Length", strconv.Itoa(len(img.body)))
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
h.Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(http.StatusForbidden)
// HEAD 不写 body(net/http 也会丢弃,这里省掉一次无意义的拷贝)。
if method != http.MethodHead {
_, _ = w.Write(assets.BannedImage)
_, _ = w.Write(img.body)
}
}
func writeBannedText(w http.ResponseWriter, method string) {
h := w.Header()
h.Set("Content-Type", "text/plain; charset=utf-8")
h.Set("Content-Length", strconv.Itoa(len(banText)))
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.WriteHeader(http.StatusForbidden)
if method != http.MethodHead {
_, _ = w.Write([]byte(banText))
}
}