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 被拒。
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -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
|
||||
+104
-14
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+120
-26
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user