// 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 ( "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/flamego/flamego" "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 } // 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) } }