Files
tamakyi 86ea96e5ca 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 被拒。
2026-10-23 15:25:07 +08:00

125 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package route
import (
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"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 是什么:
// - 配了合法图片 → 直接回图片(顶掉明文,爆破脚本靠读 "403 Forbidden"
// 确认自己被拦的那套判断会失效;人肉访问时也能看懂发生了什么);
// - 没配 / 配错 → 纯文字 "403 Forbidden",即改造前的老行为。
//
// 两条分支都显式给 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", 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(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))
}
}