diff --git a/internal/route/assets/403.webp b/internal/route/assets/403.webp new file mode 100644 index 0000000..9982548 Binary files /dev/null and b/internal/route/assets/403.webp differ 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)) } }