Files
tamabox/internal/route/honeypot_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

510 lines
17 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.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/flamego/flamego"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
)
// newHoneypotTestServer 造一个只含蜜罐 NotFound 分流的最小 flamego 实例。
//
// 刻意不调用 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()
return newHoneypotTestServerFull(t, enabled, autoBan, nil)
}
// newHoneypotTestServerFull 额外允许注入凭据文件存储(nil 表示不落凭据)。
func newHoneypotTestServerFull(t *testing.T, enabled, autoBan bool, cs honeypot.CredStore) *httptest.Server {
t.Helper()
f := flamego.New()
hp := honeypot.New(honeypot.Options{Enabled: enabled})
ab := autoban.New(autoban.Options{
Enabled: autoBan,
HoneypotEnabled: enabled,
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
Window: 60 * time.Second,
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
})
hph := newHoneypotHandler(hp, ab, nil, cs) // 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"))
})
f.Get("/api/v1/questions", func(ctx flamego.Context) {
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write([]byte("REAL_API"))
})
f.NotFound(func(ctx flamego.Context) {
if hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
ctx.Redirect("/")
})
ts := httptest.NewServer(f)
t.Cleanup(ts.Close)
return ts
}
// TestCleanPath 覆盖反代链路可能带来的脏路径。
//
// 实测 Cloudflare → nginx → 源站 上出现过 " /wp-login.php"(前导空格)。
// Match 内部会 TrimSpace 所以仍能命中,但落库值与假表单 action 用的是原始值:
// 取证数据变脏,爆破脚本还可能把表单提交到错误路径。
func TestCleanPath(t *testing.T) {
cases := map[string]string{
"/wp-login.php": "/wp-login.php",
" /wp-login.php": "/wp-login.php",
"\t/wp-login.php": "/wp-login.php",
"wp-login.php": "/wp-login.php",
"": "/",
" ": "/",
"/ADMIN": "/ADMIN", // 不改大小写:URL 路径大小写敏感
"\u200b/wp-login.php": "/wp-login.php", // 零宽空格(TrimSpace 不认)
"\ufeff/wp-login.php": "/wp-login.php", // BOM
"/wp\u200b-login.php": "/wp\u200b-login.php", // 中间的零宽字符保留(可能是 payload)
}
for in, want := range cases {
if got := cleanPath(in); got != want {
t.Errorf("cleanPath(%q) = %q, want %q", in, got, want)
}
}
}
// TestIsLoopbackIP 覆盖回环判定(含 LimitKey 折叠后的 IPv6 形式)。
func TestIsLoopbackIP(t *testing.T) {
cases := map[string]bool{
"127.0.0.1": true,
"127.1.2.3": true,
"::1": true,
"::": true,
"::/64": true, // shared.LimitKey 折叠 IPv6 后的形式
"8.8.8.8": false,
"203.0.113.9": false,
"": false,
}
for ip, want := range cases {
if got := isLoopbackIP(ip); got != want {
t.Errorf("isLoopbackIP(%q) = %v, want %v", ip, got, want)
}
}
}
// TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary),
// 而不是 302 跳首页。
func TestHoneypotServesFakeLoginPage(t *testing.T) {
ts := newHoneypotTestServer(t, true)
resp, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
if !strings.Contains(string(body), honeypot.CanaryToken) {
t.Error("假登录页缺少 canary 标记,无法溯源")
}
if !strings.Contains(string(body), `name="log"`) || !strings.Contains(string(body), `name="pwd"`) {
t.Error("假登录页应使用 WordPress 经典字段名 log / pwd")
}
if strings.Contains(string(body), "REAL_LOGIN") {
t.Error("假登录页不应包含真实业务内容")
}
}
// TestHoneypotDoesNotHijackRealRoutes 是红线测试:真实业务路由绝不能被蜜罐接管。
func TestHoneypotDoesNotHijackRealRoutes(t *testing.T) {
ts := newHoneypotTestServer(t, true)
for path, want := range map[string]string{
"/login": "REAL_LOGIN",
"/api/v1/questions": "REAL_API",
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), want) {
t.Errorf("%s 返回了 %q,期望包含 %q(业务路由被劫持)", path, truncate(string(body), 80), want)
}
if strings.Contains(string(body), honeypot.CanaryToken) {
t.Errorf("%s 命中了蜜罐,业务路由被劫持", path)
}
}
}
// TestHoneypotDisabledFallsBackToRedirect 验证:蜜罐关闭时行为与原先一致(302 → /)。
func TestHoneypotDisabledFallsBackToRedirect(t *testing.T) {
ts := newHoneypotTestServer(t, false)
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("status = %d, want 302", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/" {
t.Errorf("Location = %q, want /", loc)
}
}
// TestHoneypotCapturesCredentials 验证:POST 提交的用户名/密码能被提取出来。
func TestHoneypotCapturesCredentials(t *testing.T) {
hp := honeypot.New(honeypot.Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("log=admin&pwd=hunter2&redirect_to=%2Fwp-admin%2F"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := hp.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin", creds.Username)
}
if creds.Password != "hunter2" {
t.Errorf("Password = %q, want hunter2", creds.Password)
}
}
// TestHoneypotWritesCredentialsToFile 验证凭据落的是文件、不是数据库。
//
// 这是「明文账号密码不入库」的落地检查:POST 提交后凭据必须能从 CredStore
// 读到(说明真的写了),而 db.HoneypotHit 已经从类型上不允许携带凭据字段
// (结构体里没有 Username/Password,编译期即保证入不了库)。
func TestHoneypotWritesCredentialsToFile(t *testing.T) {
cs, err := honeypot.NewFileCredStore(t.TempDir())
if err != nil {
t.Fatalf("打开凭据目录失败:%v", err)
}
ts := newHoneypotTestServerFull(t, true, true, cs)
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=hunter2"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
if err != nil {
t.Fatalf("读取凭据文件失败:%v", err)
}
if len(recs) != 1 {
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
}
if recs[0].Username != "admin" || recs[0].Password != "hunter2" {
t.Errorf("凭据 = %q/%q, want admin/hunter2", recs[0].Username, recs[0].Password)
}
}
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
ts := newHoneypotTestServer(t, true)
resp, err := http.Post(ts.URL+"/wp-login.php",
"application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=admin"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if !strings.Contains(string(body), honeypot.LoginFailureNotice) {
t.Error("提交凭据后应返回「用户名或密码无效」的假提示")
}
}
// TestHoneypotBansEvenWhenAutoBanDisabled 是本次线上实测发现缺陷的回归测试。
//
// 现象:蜜罐命中 70+ 次,站点一次都没封禁。
// 根因:蜜罐复用了 autoban.Record,而 Record 受 enable_auto_ban 开关约束 ——
// 运维关掉自动封禁(它对共享出口误伤多)就连带让蜜罐只记录不封禁。
// 修复:蜜罐走独立的 RecordHoneypot 通道,只看 enable_honeypot。
func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
ts := newHoneypotTestServerWith(t, true, false)
// 第一次:提交凭据(这才是触发蜜罐封禁的行为;纯 GET 探测故意不立即封,
// 见 TestHoneypotProbeDoesNotBanImmediately)
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=admin"))
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))
}
}
// TestHoneypotProbeDoesNotBanImmediately 守住「诱捕能力」。
//
// 若纯 GET 也按「一次即封」处理,扫描器看一眼假登录页就吃 403,永远走不到
// POST 提交凭据那一步 —— 蜜罐退化成一次性 403 陷阱,而它的核心价值恰恰是
// 拿到对方正在用的账号密码字典。
func TestHoneypotProbeDoesNotBanImmediately(t *testing.T) {
ts := newHoneypotTestServerWith(t, true, false) // 常规自动封禁关闭,只看蜜罐通道
for i := 1; i <= 3; i++ {
resp, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("第 %d 次探测 status = %d, want 200(纯探测不应立即封禁)", i, resp.StatusCode)
}
if !strings.Contains(string(body), honeypot.CanaryToken) {
t.Fatalf("第 %d 次探测没有返回假登录页", i)
}
}
// 一旦提交凭据,必须当场封。
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=admin"))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
resp2, err := http.Get(ts.URL + "/wp-login.php")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("提交凭据后 status = %d, want 403", resp2.StatusCode)
}
}
// TestHoneypotBanBlocksBusinessRoutes 验证:被蜜罐封禁的 IP 连正常业务路由也进不去。
func TestHoneypotBanBlocksBusinessRoutes(t *testing.T) {
ts := newHoneypotTestServerWith(t, true, false)
resp, err := http.Post(ts.URL+"/wp-login.php", "application/x-www-form-urlencoded",
strings.NewReader("log=admin&pwd=admin"))
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)
}
}
// TestHoneypotRecordsExactIPv6ButBansByPrefix 验证「取证地址」与「封禁键」是两个东西。
//
// 线上踩过:用 IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
// 2408:…::/64 —— 既不像自己的地址,也说不清到底封了多大范围,
// 排查方向直接被带偏。现在取证与邮件给精确地址,封禁仍用 /64 聚合键
// (抗「单个 /64 内轮换地址」绕过)。两个断言都必须成立,否则说明又混用了。
func TestHoneypotRecordsExactIPv6ButBansByPrefix(t *testing.T) {
oldTrust := conf.Security.TrustProxy
conf.Security.TrustProxy = true
t.Cleanup(func() { conf.Security.TrustProxy = oldTrust })
cs, err := honeypot.NewFileCredStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
// 落库路径也要断言:文件与 DB 用的是同一个 clientIP,但任一处写错
// 都会让取证「只剩前缀」,而单测只有把它抓在手里才知道。
store := &captureStore{}
f := flamego.New()
hp := honeypot.New(honeypot.Options{Enabled: true})
ab := autoban.New(autoban.Options{
Enabled: true,
HoneypotEnabled: true,
ThresholdSensitive: 10,
Window: time.Minute,
Duration: time.Hour,
HoneypotThreshold: 1,
HoneypotDuration: 24 * time.Hour,
})
hph := newHoneypotHandler(hp, ab, store, cs)
f.Use(autobanMiddleware(ab, hp))
f.NotFound(func(ctx flamego.Context) {
if hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
ctx.Redirect("/")
})
ts := httptest.NewServer(f)
t.Cleanup(ts.Close)
const clientIP = "2408:8207:1234:5678::abcd"
const banKey = "2408:8207:1234:5678::/64"
post, err := http.NewRequest(http.MethodPost, ts.URL+"/wp-login.php",
strings.NewReader("log=admin&pwd=hunter2"))
if err != nil {
t.Fatal(err)
}
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
post.Header.Set("CF-Connecting-IP", clientIP)
resp, err := http.DefaultClient.Do(post)
if err != nil {
t.Fatal(err)
}
_, _ = io.ReadAll(resp.Body)
resp.Body.Close()
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 {
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
}
if recs[0].IP != clientIP {
t.Errorf("取证 IP = %q, want 精确地址 %q(不能写 /64 聚合键)", recs[0].IP, clientIP)
}
if len(store.hits) != 1 {
t.Fatalf("落库记录数 = %d, want 1", len(store.hits))
}
if store.hits[0].IP != clientIP {
t.Errorf("落库 IP = %q, want 精确地址 %q(元数据表同样是取证物)", store.hits[0].IP, clientIP)
}
if !ab.IsBanned(banKey) {
t.Errorf("封禁键 %q 未生效:IPv6 必须按 /64 聚合", banKey)
}
if ab.IsBanned(clientIP) {
t.Errorf("精确地址 %q 不应单独成为封禁键:写入与判定必须是同一口径", clientIP)
}
// 同 /64 内换个地址仍应被拦 —— 这才是按前缀聚合的实际价值。
get, err := http.NewRequest(http.MethodGet, ts.URL+"/anything", nil)
if err != nil {
t.Fatal(err)
}
get.Header.Set("CF-Connecting-IP", "2408:8207:1234:5678::9999")
resp2, err := http.DefaultClient.Do(get)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("同 /64 内换地址 status = %d, want 403", resp2.StatusCode)
}
}
// captureStore 是 db.HoneypotStore 的极简实现:只把 Create 的记录留下,
// 其余方法返回零值。真库测试有 DB 门控、不适合放进这组结构性用例,
// 而「落库的 IP 是不是精确地址」又必须被钉住(混用聚合键正是曾经的缺陷)。
type captureStore struct {
hits []*db.HoneypotHit
}
func (c *captureStore) Create(_ context.Context, hit *db.HoneypotHit) error {
c.hits = append(c.hits, hit)
return nil
}
func (c *captureStore) ListSince(context.Context, time.Time, int) ([]*db.HoneypotHit, error) {
return nil, nil
}
func (c *captureStore) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
func (c *captureStore) TopSources(context.Context, time.Time, int) ([]*db.HoneypotSourceStat, error) {
return nil, nil
}
func (c *captureStore) TopPaths(context.Context, time.Time, int) ([]*db.HoneypotPathStat, error) {
return nil, nil
}
func (c *captureStore) DeleteBefore(context.Context, time.Time) (int64, error) { return 0, nil }
func (c *captureStore) DeleteOldestBeyond(context.Context, int) (int64, error) { return 0, nil }
func (c *captureStore) Compact(context.Context) error { return nil }
func (c *captureStore) Size(context.Context) (db.HoneypotSize, error) {
return db.HoneypotSize{}, nil
}
func (c *captureStore) LegacyCredentialColumns(context.Context) ([]string, error) {
return nil, nil
}
func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, error) {
return nil, nil
}