fix(honeypot): 蜜罐封禁与 enable_auto_ban 解耦 + 泄露扫描后缀匹配

- autoban: 新增 RecordHoneypot 独立通道(不受 enabled 约束)+ 独立阈值/时长;
  IsBanned 不再因 enable_auto_ban=false 而忽略已有封禁
- honeypot: 新增 honeypotSuffixes 后缀匹配,覆盖 /backup.zip、/db.sql、/id_rsa.pem
- 新增配置 honeypot_threshold / honeypot_ban_minutes
- 补测试:autoban/honeypot_test.go、route 侧禁用自动封禁仍封禁
This commit is contained in:
tamakyi
2026-10-21 12:36:33 +08:00
parent 79051acae8
commit 7d52155411
12 changed files with 402 additions and 10 deletions
+71 -3
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/flamego/flamego"
@@ -22,17 +23,29 @@ import (
// 刻意不调用 route.New()(那会拉起 DB/Redis/session 依赖),而是复刻它的
// 关键结构:全局 Use 链 → 具体路由 → NotFound 蜜罐分流。
func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
t.Helper()
return newHoneypotTestServerWith(t, enabled, true)
}
// newHoneypotTestServerWith 可分别控制「蜜罐开关」与「常规自动封禁开关」,
// 用于验证两者相互独立(见 autoban.Options.HoneypotEnabled)。
func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Server {
t.Helper()
f := flamego.New()
hp := honeypot.New(honeypot.Options{Enabled: enabled})
ab := autoban.New(autoban.Options{
Enabled: true,
Enabled: autoBan,
HoneypotEnabled: enabled,
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
Window: 60,
Duration: 60,
Window: 60 * time.Second,
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
})
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
f.Use(autobanMiddleware(ab, hp))
f.Get("/login", func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write([]byte("REAL_LOGIN"))
@@ -166,3 +179,58 @@ func TestHoneypotPostReturnsFailurePage(t *testing.T) {
t.Error("提交凭据后应返回「用户名或密码无效」的假提示")
}
}
// TestHoneypotBansEvenWhenAutoBanDisabled 是本次线上实测发现缺陷的回归测试。
//
// 现象:蜜罐命中 70+ 次,站点一次都没封禁。
// 根因:蜜罐复用了 autoban.Record,而 Record 受 enable_auto_ban 开关约束 ——
// 运维关掉自动封禁(它对共享出口误伤多)就连带让蜜罐只记录不封禁。
// 修复:蜜罐走独立的 RecordHoneypot 通道,只看 enable_honeypot。
func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
ts := newHoneypotTestServerWith(t, true, false)
// 第一次命中蜜罐
resp, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("首次命中 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
}
// 第二次:应已被蜜罐封禁 → 403
resp2, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
body, _ := io.ReadAll(resp2.Body)
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))
}
}
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
ts := newHoneypotTestServerWith(t, true, false)
resp, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
resp2, err := http.Get(ts.URL + "/login")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want 403(被封禁的 IP 不应还能访问业务路由)", resp2.StatusCode)
}
}