From 0318ba81b46a4a6e2a82f385535971907cfde9e6 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 15:04:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(autoban):=20=E5=B0=81=E7=A6=81=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BD=93=E6=94=B9=E6=88=90=E4=B8=80=E5=BC=A0=20403=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被封禁来源访问任何路径时,原来只回 13 字节的 "403 Forbidden":既没有信息量, 人肉访问(尤其是共享出口被连累的误封)时看着像站点挂了;扫描/爆破脚本也常靠 读这行明文确认「自己已被拦」。 改为返回一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),状态码不变: - 图片用 //go:embed 编进二进制,不依赖外部 URL、不占公开路径; - 显式给 Content-Length(8 KB 超过 Go 的 2 KB 写缓冲,不设会退回 chunked); - Cache-Control: no-store —— 中间层缓存 403 没有意义,还会让解封后的响应继续命中; - HEAD 只回头不写 body; - 放在 internal/route/assets/ 而不是 static/:static 下的东西有可公开访问的 URL, 等于多一个指纹入口。 注意链接:给的 R2 预签名地址 X-Amz-Expires=604800(7 天),热链到期就会挂, 所以是把字节取回来打包,而不是重定向或代理。 测试: - TestWriteBanned:状态码 403、image/webp、Content-Length 与内嵌字节数一致、 body 前 12 字节是 RIFF/WEBP(防止被换成 HTML 或明文这类回归); - TestWriteBannedHead:HEAD 无 body 但有 Content-Length; - 原 TestHoneypotBansEvenWhenAutoBanDisabled 里断言 "403 Forbidden" 明文的用例 改为断言 image/webp 与体积。 变异验证:把 writeBanned 改回写明文 + text/plain,两条用例均按预期变红。 --- internal/route/assets/403.webp | Bin 0 -> 8608 bytes internal/route/assets/assets.go | 20 ++++++++++++ internal/route/autoban.go | 5 +-- internal/route/banned.go | 34 ++++++++++++++++++++ internal/route/banned_test.go | 55 ++++++++++++++++++++++++++++++++ internal/route/honeypot_test.go | 8 +++-- 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 internal/route/assets/403.webp create mode 100644 internal/route/assets/assets.go create mode 100644 internal/route/banned.go create mode 100644 internal/route/banned_test.go diff --git a/internal/route/assets/403.webp b/internal/route/assets/403.webp new file mode 100644 index 0000000000000000000000000000000000000000..99825489ff1503eb7cc37d2f44a31b9d3d52e545 GIT binary patch literal 8608 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 literal 0 HcmV?d00001 diff --git a/internal/route/assets/assets.go b/internal/route/assets/assets.go new file mode 100644 index 0000000..5eaaa7e --- /dev/null +++ b/internal/route/assets/assets.go @@ -0,0 +1,20 @@ +// 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/autoban.go b/internal/route/autoban.go index 3764c14..cad6c07 100644 --- a/internal/route/autoban.go +++ b/internal/route/autoban.go @@ -1,8 +1,6 @@ package route import ( - "net/http" - "github.com/flamego/flamego" "github.com/sirupsen/logrus" @@ -34,8 +32,7 @@ func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler "ban_key": ip, "path": path, }).Debug("拒绝已封禁来源") - ctx.ResponseWriter().WriteHeader(http.StatusForbidden) - _, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden")) + writeBanned(ctx.ResponseWriter(), ctx.Request().Request.Method) return } diff --git a/internal/route/banned.go b/internal/route/banned.go new file mode 100644 index 0000000..d374c35 --- /dev/null +++ b/internal/route/banned.go @@ -0,0 +1,34 @@ +package route + +import ( + "net/http" + "strconv" + + "github.com/tamakyi/TamaBox/internal/route/assets" +) + +// writeBanned 写出「来源已被封禁」的响应。 +// +// 状态码仍然是 403 —— 语义不变,只把 body 从 13 字节的 "403 Forbidden" +// 换成一张 256×256 的 webp(约 8.6 KB): +// - 顶掉明文:爆破/扫描脚本不少会读响应体里的 "403 Forbidden" 来确认自己 +// 已被拦,换掉 body 至少让这类判断失效(状态码本身还是照常返回); +// - 给人看:误封(比如共享出口、代理)时打开浏览器看到一张写着 403 的图, +// 比一个没有任何样式的白屏好判断得多。 +// +// 图片是编译期 embed 的,不走磁盘 IO,也不占一个可公开访问的 URL。 +// 被封住的来源往往反复重试,所以这里显式给 Content-Length(否则 8 KB 会退回 +// chunked),并标 no-store:中间层缓存一张 403 图没有意义,还会让解封后 +// 的响应继续命中缓存。 +func writeBanned(w http.ResponseWriter, method string) { + h := w.Header() + h.Set("Content-Type", "image/webp") + h.Set("Content-Length", strconv.Itoa(len(assets.BannedImage))) + 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) + } +} diff --git a/internal/route/banned_test.go b/internal/route/banned_test.go new file mode 100644 index 0000000..00cd4fd --- /dev/null +++ b/internal/route/banned_test.go @@ -0,0 +1,55 @@ +package route + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/tamakyi/TamaBox/internal/route/assets" +) + +// TestWriteBanned 守住「被封禁后的响应是一张图」。 +func TestWriteBanned(t *testing.T) { + rec := httptest.NewRecorder() + writeBanned(rec, http.MethodGet) + + if rec.Code != http.StatusForbidden { + 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 cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(len(assets.BannedImage)) { + t.Errorf("Content-Length = %q, want %d", cl, len(assets.BannedImage)) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("Cache-Control = %q, want 含 no-store(中间层缓存 403 无意义)", 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))]) + } + if strings.Contains(string(body), "403 Forbidden") && len(body) < 512 { + t.Error("body 仍是明文文案,没换成图片") + } +} + +// TestWriteBannedHead 覆盖 HEAD:头照给(含 Content-Length),body 不发。 +func TestWriteBannedHead(t *testing.T) { + rec := httptest.NewRecorder() + writeBanned(rec, http.MethodHead) + + 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)) + } +} diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go index 7873f3d..0b90b0b 100644 --- a/internal/route/honeypot_test.go +++ b/internal/route/honeypot_test.go @@ -297,8 +297,12 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) { if resp2.StatusCode != http.StatusForbidden { t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode) } - if !strings.Contains(string(body), "403 Forbidden") { - t.Errorf("body = %q, want 含 \"403 Forbidden\"", truncate(string(body), 80)) + // body 是一张 403 图(见 writeBanned),不再是那行明文。 + if ct := resp2.Header.Get("Content-Type"); ct != "image/webp" { + t.Errorf("Content-Type = %q, want image/webp", ct) + } + if len(body) < 1024 { + t.Errorf("body 只有 %d 字节,want 一张图片", len(body)) } }