Files
tamabox/internal/route/honeypot_test.go
T
tamakyi 846bb8a3d9 fix(honeypot): 清洗反代链路的脏路径,回环来源 IP 打配置告警
测试环境实测(Cloudflare → nginx → 源站)发现两处:
1. 凭据文件里记录的 path 是 " /wp-login.php"(前导空格);
2. 来源 IP 记成 127.0.0.1。

path:Match 内部会 TrimSpace 所以仍能命中,但落库与假表单 action 用的是原始
req.URL.Path —— 取证数据带脏字符,爆破脚本还可能把表单提交到错误路径。
新增 cleanPath(trim + 补前导斜杠,不改大小写) 在 Serve 入口清洗一次。

IP:记录到回环地址说明站点在反代后面但 [security] trust_proxy 未开启。
此时所有访客在源站眼里是同一个 IP:取证数据失去意义,且一次蜜罐封禁会
连带封掉整站(含普通访客)。新增一次性 WARN(24h 冷却),只提示不改行为 ——
本地直连调试时回环 IP 属正常。sample.ini 的 trust_proxy 段补对应说明。

测试:TestCleanPath / TestIsLoopbackIP 覆盖前导空格、制表符、空串、
无前导斜杠、大小写保持,以及 shared.LimitKey 折叠后的 "::/64" 形式。
2026-10-21 15:54:39 +08:00

360 lines
12 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 (
"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
}
// 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 路径大小写敏感
}
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)
}
}