From 86ea96e5cafa8255b32513508c773145793ee67b Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 15:25:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(autoban):=20=E5=B0=81=E7=A6=81=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=9B=BE=E6=94=B9=E4=B8=BA=20ban=5Fimage=5Fpath=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E9=BB=98=E8=AE=A4=E4=BB=8D=E6=98=AF?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 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 被拒。 --- .workbuddy/memory/2026-10-23.md | 26 ++++++ conf/app.sample.ini | 8 ++ docs/honeypot.md | 24 +++++- internal/conf/static.go | 10 ++- internal/route/assets/403.webp | Bin 8608 -> 0 bytes internal/route/assets/assets.go | 20 ----- internal/route/banned.go | 118 +++++++++++++++++++++++--- internal/route/banned_test.go | 146 ++++++++++++++++++++++++++------ internal/route/honeypot_test.go | 11 +-- internal/route/route.go | 5 ++ 10 files changed, 298 insertions(+), 70 deletions(-) delete mode 100644 internal/route/assets/403.webp delete mode 100644 internal/route/assets/assets.go diff --git a/.workbuddy/memory/2026-10-23.md b/.workbuddy/memory/2026-10-23.md index 0475083..6375e2a 100644 --- a/.workbuddy/memory/2026-10-23.md +++ b/.workbuddy/memory/2026-10-23.md @@ -179,3 +179,29 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 - 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。 - 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽, 已压到 256×256;别塞几百 KB 原图。 + +### 关于「403 图放 R2 是否更好」的结论 + +结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」—— +字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取, +那就要放弃「403 直接是图片」,改成 403 + 一个 `` 的 HTML。 +而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域 +(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。 +8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。 +若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。 + +### 改为 ban_image_path(配置驱动,默认纯文字) + +按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除, +行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。 +- `[security] ban_image_path`(相对路径以进程工作目录为基准); +- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`, + 热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500); +- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成 + app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条); +- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给; + HEAD 不写 body;`no-store`。 +- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。 + +**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉, +否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。 diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 3bf0e70..c8cca5b 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -153,6 +153,14 @@ honeypot_ban_minutes = 1440 ; 建议单独挂一个卷,并让它不参与备份。 honeypot_cred_dir = data/honeypot-creds +; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。 +; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。 +; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝, +; 否则等于把配置文件当响应体吐给攻击者。 +; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试, +; 每张图都会照常占用出网带宽。 +ban_image_path = + ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。 default_moderation_strictness = ; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。 diff --git a/docs/honeypot.md b/docs/honeypot.md index f232726..d4823d0 100644 --- a/docs/honeypot.md +++ b/docs/honeypot.md @@ -112,19 +112,35 @@ honeypot_cred_dir = data/honeypot-creds ### 被封之后会看到什么 -状态码 `403`,body 是一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),不再是那行 13 字节的明文: +状态码永远 `403`,body 由 `[security] ban_image_path` 决定: + +```ini +[security] +ban_image_path = data/403.webp +``` + +| 情况 | 响应体 | +|---|---| +| 配了合法图片 | 图片本身(`Content-Type` 按内容嗅探,png/jpeg/gif/webp 都行) | +| 留空 | 纯文字 `403 Forbidden`(改造前的老行为) | +| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 `403 Forbidden` + 启动日志 Warn | ``` -$ curl -si https://你的站点/anything | head -4 +$ curl -si https://你的站点/anything | head -5 HTTP/2 403 content-type: image/webp content-length: 8608 cache-control: no-store, no-cache, must-revalidate ``` -这样做有两个目的:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。 +为什么要能换成图:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是**可选**的——配错了不会影响封禁本身,只是回到纯文字。 -图片用 `go:embed` 编进二进制(`internal/route/assets/403.webp`),不占任何可公开访问的 URL。想换成自己的图:覆盖同目录文件后重新编译、重启即可。注意体积——被封的来源会反复重试,8.6 KB 是刻意压过的(原图别直接塞几百 KB 的)。 +几个约束,都是刻意的: + +- **只认内容,不认扩展名**(`http.DetectContentType`)。把 `ban_image_path` 配成 `app.ini` / `.env` 会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。 +- **上限 2 MB**。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。 +- **启动时读一次**,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。 +- `no-store`:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。 ### 封的是一把「键」,不是一个人 diff --git a/internal/conf/static.go b/internal/conf/static.go index 044ba04..19694f4 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -125,7 +125,15 @@ var ( // 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。 // 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。 // 目录不可写时只降级为「不保存凭据」,不会影响站点启动。 - HoneypotCredDir string `ini:"honeypot_cred_dir"` + HoneypotCredDir string `ini:"honeypot_cred_dir"` + // BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。 + // + // 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" —— + // 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。 + // + // 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警, + // 否则等于把配置文件当响应体吐给每一个被封的攻击者。 + BanImagePath string `ini:"ban_image_path"` SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送) } diff --git a/internal/route/assets/403.webp b/internal/route/assets/403.webp deleted file mode 100644 index 99825489ff1503eb7cc37d2f44a31b9d3d52e545..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8608 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 diff --git a/internal/route/assets/assets.go b/internal/route/assets/assets.go deleted file mode 100644 index 5eaaa7e..0000000 --- a/internal/route/assets/assets.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package assets 存放「必须由服务端直接吐出」的二进制资源。 -// -// 为什么不放 static/:static 下的东西由静态文件中间件按 URL 提供, -// 而这里的图片只该出现在 403 响应体里 —— 不该有一个能直接访问的公开路径 -// (否则被 ban 的人可以先抓一张「解禁图」,也等于多一个指纹入口)。 -package assets - -import _ "embed" - -// BannedImage 是被封禁来源访问任何路径时返回的图(256×256 webp,约 8.6 KB)。 -// -// 换成图片的动机:被封之后每次请求都只拿到 13 字节的 "403 Forbidden", -// 既没有信息量,也让人误以为是站点挂了。这张图明确写着 403 Forbidden, -// 人一看就懂,而状态码仍然是 403 —— 语义没变,只换了 body。 -// -// 想替换:直接覆盖同目录的 403.webp 后重新编译(go:embed 是编译期打包的, -// 不需要重启之外的操作)。 -// -//go:embed 403.webp -var BannedImage []byte diff --git a/internal/route/banned.go b/internal/route/banned.go index d374c35..e7c0f2f 100644 --- a/internal/route/banned.go +++ b/internal/route/banned.go @@ -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)) } } diff --git a/internal/route/banned_test.go b/internal/route/banned_test.go index 00cd4fd..455f225 100644 --- a/internal/route/banned_test.go +++ b/internal/route/banned_test.go @@ -1,55 +1,149 @@ package route import ( + "encoding/base64" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "strings" "testing" - - "github.com/tamakyi/TamaBox/internal/route/assets" ) -// TestWriteBanned 守住「被封禁后的响应是一张图」。 -func TestWriteBanned(t *testing.T) { +// tinyPNG 是 1×1 的合法 PNG,用来验证「配了图就回图」这条分支。 +const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + +func writeTempImage(t *testing.T, data []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "ban.png") + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func decodeTinyPNG(t *testing.T) []byte { + t.Helper() + b, err := base64.StdEncoding.DecodeString(tinyPNG) + if err != nil { + t.Fatal(err) + } + return b +} + +// TestWriteBannedTextFallback 守住默认行为:没配图时就是改造前的纯文字 403。 +func TestWriteBannedTextFallback(t *testing.T) { + if loadBanImage("") { + t.Fatal("空路径不应装载出图") + } rec := httptest.NewRecorder() writeBanned(rec, http.MethodGet) if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403(换成图片不改变状态码语义)", rec.Code) + t.Fatalf("status = %d, want 403", rec.Code) } - if ct := rec.Header().Get("Content-Type"); ct != "image/webp" { - t.Errorf("Content-Type = %q, want image/webp", ct) + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain(没配图时回退纯文字)", ct) } - if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) { - t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage)) + if body := rec.Body.String(); body != banText { + t.Errorf("body = %q, want %q", body, banText) + } + if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(banText)) { + t.Errorf("Content-Length = %q, want %d", cl, len(banText)) + } +} + +// TestWriteBannedServesConfiguredImage 守住「配了图就回图」。 +func TestWriteBannedServesConfiguredImage(t *testing.T) { + data := decodeTinyPNG(t) + if !loadBanImage(writeTempImage(t, data)) { + t.Fatal("合法的 PNG 应装载成功") + } + t.Cleanup(func() { loadBanImage("") }) + + rec := httptest.NewRecorder() + writeBanned(rec, http.MethodGet) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403(换 body 不改变状态码语义)", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/png" { + t.Errorf("Content-Type = %q, want image/png(按内容嗅探,不看扩展名)", ct) + } + if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(data)) { + t.Errorf("Content-Length = %q, want %d", cl, len(data)) + } + if body := rec.Body.Bytes(); string(body) != string(data) { + t.Errorf("body 与文件内容不一致(%d vs %d 字节)", len(body), len(data)) } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { - t.Errorf("Cache-Control = %q, want 含 no-store(中间层缓存 403 无意义)", cc) + t.Errorf("Cache-Control = %q, want 含 no-store", cc) } +} - body := rec.Body.Bytes() - // webp 的容器头:RIFF....WEBP。写成字节判断,避免「图片被换成 HTML」这类回归。 - if len(body) < 12 || string(body[0:4]) != "RIFF" || string(body[8:12]) != "WEBP" { - t.Errorf("body 不是 webp(前 12 字节 %q)", body[:min(12, len(body))]) +// TestLoadBanImageRejectsBadInput 覆盖各种配置错误:都必须降级为纯文字, +// 而不是报错、更不是把文件内容原样吐出去。 +func TestLoadBanImageRejectsBadInput(t *testing.T) { + t.Cleanup(func() { loadBanImage("") }) + + cases := []struct { + name string + path string + }{ + {"不存在的文件", filepath.Join(t.TempDir(), "nope.png")}, + {"是个目录", t.TempDir()}, + {"不是图片", writeTempImage(t, []byte("[server]\nsalt = secret\n"))}, + {"空文件", writeTempImage(t, nil)}, } - if strings.Contains(string(body), "403 Forbidden") && len(body) < 512 { - t.Error("body 仍是明文文案,没换成图片") + for _, c := range cases { + if loadBanImage(c.path) { + t.Errorf("%s: 不应装载成功", c.name) + } + rec := httptest.NewRecorder() + writeBanned(rec, http.MethodGet) + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("%s: 应回退纯文字,实际 Content-Type = %q", c.name, ct) + } + if body := rec.Body.String(); strings.Contains(body, "salt") { + t.Errorf("%s: 把文件内容当响应体吐了出去:%q", c.name, body) + } } } +// TestLoadBanImageTooLarge 守住体积上限:被封来源反复重试,大图等于放大出网带宽。 +func TestLoadBanImageTooLarge(t *testing.T) { + big := make([]byte, maxBanImageBytes+1) + copy(big, []byte("\x89PNG\r\n\x1a\n")) + if loadBanImage(writeTempImage(t, big)) { + t.Error("超过 2 MB 的文件不应被装载") + } + loadBanImage("") +} + // TestWriteBannedHead 覆盖 HEAD:头照给(含 Content-Length),body 不发。 func TestWriteBannedHead(t *testing.T) { - rec := httptest.NewRecorder() - writeBanned(rec, http.MethodHead) + for _, withImage := range []bool{false, true} { + if withImage { + data := decodeTinyPNG(t) + if !loadBanImage(writeTempImage(t, data)) { + t.Fatal("装载失败") + } + } else { + loadBanImage("") + } - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403", rec.Code) - } - if rec.Body.Len() != 0 { - t.Errorf("HEAD 不应有 body,实际 %d 字节", rec.Body.Len()) - } - if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) { - t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage)) + rec := httptest.NewRecorder() + writeBanned(rec, http.MethodHead) + if rec.Code != http.StatusForbidden { + t.Errorf("withImage=%v: status = %d, want 403", withImage, rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("withImage=%v: HEAD 不应有 body,实际 %d 字节", withImage, rec.Body.Len()) + } + if rec.Header().Get("Content-Length") == "" { + t.Errorf("withImage=%v: HEAD 也应给出 Content-Length", withImage) + } } + loadBanImage("") } diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go index 0b90b0b..8907b7a 100644 --- a/internal/route/honeypot_test.go +++ b/internal/route/honeypot_test.go @@ -297,12 +297,13 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) { if resp2.StatusCode != http.StatusForbidden { t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode) } - // body 是一张 403 图(见 writeBanned),不再是那行明文。 - if ct := resp2.Header.Get("Content-Type"); ct != "image/webp" { - t.Errorf("Content-Type = %q, want image/webp", ct) + // 没配 ban_image_path 时,封禁响应就是纯文字(见 writeBanned)。 + // 配了图的分支由 banned_test.go 覆盖。 + if ct := resp2.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain", ct) } - if len(body) < 1024 { - t.Errorf("body 只有 %d 字节,want 一张图片", len(body)) + if !strings.Contains(string(body), banText) { + t.Errorf("body = %q, want 含 %q", truncate(string(body), 80), banText) } } diff --git a/internal/route/route.go b/internal/route/route.go index 77748c1..eb026de 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -136,6 +136,11 @@ func New() *flamego.Flame { // 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。 ab := newBanner() + // 封禁响应图:启动时读一次,之后每个被封请求只读内存。 + // 留空 / 读不到 / 不是图片 → 回退纯文字 403(见 loadBanImage), + // 绝不因为一张图把站点拦在启动阶段。 + loadBanImage(conf.Security.BanImagePath) + // 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。 // 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。 hp := honeypot.New(honeypot.Options{