feat(autoban): 封禁响应体改成一张 403 图片

被封禁来源访问任何路径时,原来只回 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,两条用例均按预期变红。
This commit is contained in:
tamakyi
2026-10-23 15:04:13 +08:00
parent 9d3f5a72fa
commit 0318ba81b4
6 changed files with 116 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+20
View File
@@ -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
+1 -4
View File
@@ -1,8 +1,6 @@
package route package route
import ( import (
"net/http"
"github.com/flamego/flamego" "github.com/flamego/flamego"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@@ -34,8 +32,7 @@ func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler
"ban_key": ip, "ban_key": ip,
"path": path, "path": path,
}).Debug("拒绝已封禁来源") }).Debug("拒绝已封禁来源")
ctx.ResponseWriter().WriteHeader(http.StatusForbidden) writeBanned(ctx.ResponseWriter(), ctx.Request().Request.Method)
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
return return
} }
+34
View File
@@ -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)
}
}
+55
View File
@@ -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))
}
}
+6 -2
View File
@@ -297,8 +297,12 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
if resp2.StatusCode != http.StatusForbidden { if resp2.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode) t.Errorf("status = %d, want 403(蜜罐封禁不得被 enable_auto_ban 连坐)", resp2.StatusCode)
} }
if !strings.Contains(string(body), "403 Forbidden") { // body 是一张 403 图(见 writeBanned),不再是那行明文。
t.Errorf("body = %q, want 含 \"403 Forbidden\"", truncate(string(body), 80)) 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))
} }
} }