Files
tamabox/internal/security/autoban/autoban_test.go
T
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00

224 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package autoban
import (
"sync/atomic"
"testing"
"time"
)
func newTestBanner(thresholdSensitive, threshold404 int, window, duration time.Duration, alertEmail string, alertCalls *int64) *Banner {
return New(Options{
Enabled: true,
TrackNotFound: true, // 测试 helper 显式开启 404 跟踪,覆盖两种计数的既有用例
ThresholdSensitive: thresholdSensitive,
Threshold404: threshold404,
Window: window,
Duration: duration,
AlertEmail: alertEmail,
SendAlert: func(ip string, paths []string, dur int) error {
if alertCalls != nil {
atomic.AddInt64(alertCalls, 1)
}
return nil
},
})
}
func TestSensitiveThreshold(t *testing.T) {
b := newTestBanner(2, 5, time.Minute, time.Minute, "", nil)
b.Record("1.2.3.4", "/admin", KindSensitive)
if b.IsBanned("1.2.3.4") {
t.Fatal("should not be banned before reaching threshold")
}
b.Record("1.2.3.4", "/.env", KindSensitive)
if !b.IsBanned("1.2.3.4") {
t.Fatal("expected ban after reaching sensitive threshold")
}
}
func TestNotFoundThreshold(t *testing.T) {
b := newTestBanner(10, 3, time.Minute, time.Minute, "", nil)
for i := 0; i < 3; i++ {
b.Record("5.6.7.8", "/random-"+string(rune('a'+i)), KindNotFound)
}
if !b.IsBanned("5.6.7.8") {
t.Fatal("expected ban after reaching 404 threshold")
}
}
func TestNotFoundTrackDisabledByDefault(t *testing.T) {
// 默认(TrackNotFound=false)下 404 不参与统计:正常业务路径一律放行,
// 仅敏感扫描路径仍计数封禁——防止共享出口下正常访问被 404 探测连坐误伤。
b := New(Options{Enabled: true, ThresholdSensitive: 1, Threshold404: 1, Window: time.Minute, Duration: time.Minute})
for i := 0; i < 5; i++ {
b.Record("7.7.7.7", "/random-"+string(rune('a'+i)), KindNotFound)
}
if b.IsBanned("7.7.7.7") {
t.Fatal("404 events must not accumulate when TrackNotFound is false")
}
b.Record("7.7.7.7", "/.env", KindSensitive)
if !b.IsBanned("7.7.7.7") {
t.Fatal("sensitive events must still ban even when 404 tracking is off")
}
}
func TestWindowExpiry(t *testing.T) {
b := newTestBanner(2, 5, 50*time.Millisecond, time.Minute, "", nil)
b.Record("1.1.1.1", "/admin", KindSensitive)
time.Sleep(80 * time.Millisecond) // 事件滑出窗口
b.Record("1.1.1.1", "/.env", KindSensitive)
if b.IsBanned("1.1.1.1") {
t.Fatal("events outside the window must not accumulate")
}
}
func TestDurationExpiry(t *testing.T) {
b := newTestBanner(1, 5, time.Minute, 60*time.Millisecond, "", nil)
b.Record("2.2.2.2", "/admin", KindSensitive)
if !b.IsBanned("2.2.2.2") {
t.Fatal("expected ban")
}
time.Sleep(100 * time.Millisecond) // 封禁到期
if b.IsBanned("2.2.2.2") {
t.Fatal("ban should expire after duration")
}
}
func TestAlertOncePerIP(t *testing.T) {
var calls int64
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
b.Record("9.9.9.9", "/wp-admin", KindSensitive)
b.Record("9.9.9.9", "/.git", KindSensitive) // 已在封禁中,不应重复告警
b.Record("9.9.9.9", "/config", KindSensitive)
// sendAlert 异步调用,轮询等待最多 500ms
deadline := time.Now().Add(500 * time.Millisecond)
for atomic.LoadInt64(&calls) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := atomic.LoadInt64(&calls); got != 1 {
t.Fatalf("expected exactly 1 alert, got %d", got)
}
}
func TestDisabled(t *testing.T) {
b := New(Options{Enabled: false})
b.Record("3.3.3.3", "/admin", KindSensitive)
if b.IsBanned("3.3.3.3") {
t.Fatal("disabled auto-ban must not ban")
}
}
func TestDefaults(t *testing.T) {
b := New(Options{Enabled: true}) // 全部走默认值
if b.thresholdSensitive != DefaultThresholdSensitive || b.threshold404 != DefaultThreshold404 {
t.Fatalf("default thresholds wrong: %d/%d", b.thresholdSensitive, b.threshold404)
}
if b.window != DefaultWindowSeconds*time.Second || b.duration != DefaultDurationMinutes*time.Minute {
t.Fatalf("default window/duration wrong: %v/%v", b.window, b.duration)
}
if b.trackNotFound {
t.Fatal("404 tracking must be disabled by default")
}
}
func TestIsSensitivePath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/admin", true},
{"/admin/panel", true},
{"/WP-ADMIN", true},
{"/.env", true},
{"/user/questions", false},
{"/vote/abc123", false},
{"/administratorx", false}, // 前缀必须完整段匹配,避免误伤
// 目录枚举 / 双斜杠变体(wlwmanifest 扫描):前缀匹配漏网,片段匹配兜底
{"//wp/wp-includes/wlwmanifest.xml", true},
{"//news/wp-includes/wlwmanifest.xml", true},
{"//2018/wp-includes/wlwmanifest.xml", true},
{"//shop/wp-includes/wlwmanifest.xml", true},
{"//test/xmlrpc.php", true},
{"/blog/wp-json/", true},
{"/wp-content/uploads/evil.php", true},
// 业务路径不得被片段匹配误伤
{"/_/tamakyi", false},
{"/_/admin6", false},
{"/api/v1/question/abc123", false},
{"/explore/tags/golang", false},
{"/i18n/zh-CN", false},
}
for _, c := range cases {
if got := IsSensitivePath(c.path); got != c.want {
t.Errorf("IsSensitivePath(%q) = %v, want %v", c.path, got, c.want)
}
}
}
// TestOnBanCallback 验证建封时会回调上层(键 / 时长 / 原因),
// 且「已在封禁期内重复命中」不会重复回调。
//
// 存在的意义:封禁表是纯内存的,外部完全看不见。线上出现过
// 「邮件说封了、实际还能访问」,而当时没有任何记录能说明封的是哪个键、
// 到什么时候解除 —— 这个回调就是那条记录的唯一来源。
func TestOnBanCallback(t *testing.T) {
type banEvent struct {
key string
duration time.Duration
reason string
}
events := make(chan banEvent, 4)
b := New(Options{
Enabled: true,
ThresholdSensitive: 1,
TrackNotFound: false,
Window: time.Minute,
Duration: 10 * time.Minute,
HoneypotEnabled: true,
HoneypotThreshold: 1,
HoneypotDuration: 24 * time.Hour,
OnBan: func(key string, duration time.Duration, reason string) {
events <- banEvent{key: key, duration: duration, reason: reason}
},
})
const key = "2408:8207:1234:5678::/64"
if !b.RecordHoneypot(key, "/wp-login.php") {
t.Fatal("首次蜜罐命中应触发封禁")
}
select {
case e := <-events:
if e.key != key {
t.Errorf("回调键 = %q, want %q", e.key, key)
}
if e.duration != 24*time.Hour {
t.Errorf("回调时长 = %v, want 24h(蜜罐应走 HoneypotDuration)", e.duration)
}
if e.reason != ReasonHoneypot {
t.Errorf("回调原因 = %q, want %q", e.reason, ReasonHoneypot)
}
case <-time.After(time.Second):
t.Fatal("蜜罐封禁没有触发 OnBan 回调")
}
// 已在封禁期内再次命中:不新建封禁,也就不应重复回调。
b.RecordHoneypot(key, "/wp-login.php")
select {
case e := <-events:
t.Fatalf("重复命中不应重复回调,收到 %+v", e)
case <-time.After(50 * time.Millisecond):
}
// 常规通道(敏感路径)也要回调,且时长用常规 Duration、原因可区分。
b.Record("1.2.3.4", "/admin", KindSensitive)
select {
case e := <-events:
if e.key != "1.2.3.4" || e.reason != ReasonProbe || e.duration != 10*time.Minute {
t.Errorf("常规封禁回调 = %+v, want key=1.2.3.4 reason=probe duration=10m", e)
}
case <-time.After(time.Second):
t.Fatal("常规封禁没有触发 OnBan 回调")
}
}