Files
tamabox/internal/route/honeypot_test.go
T
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00

647 lines
22 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
}
// TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。
//
// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
// 所有路径都回 WordPress 页面等于告诉对方「这里没有 PMA/Jenkins」。
func TestHoneypotServesFamilySpecificPage(t *testing.T) {
ts := newHoneypotTestServerFull(t, true, false, nil)
cases := []struct {
path string
want string
}{
{"/wp-login.php", `name="log"`},
{"/phpmyadmin/index.php", `name="pma_username"`},
{"/jenkins/login", `name="j_username"`},
{"/cgi-bin/luci", `name="luci_username"`},
{"/users/sign_in", `name="user[login]"`},
{"/administrator/index.php", `name="passwd"`},
}
for _, c := range cases {
resp, err := http.Get(ts.URL + c.path)
if err != nil {
t.Fatalf("GET %s: %v", c.path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", c.path, resp.StatusCode)
}
if !strings.Contains(string(body), c.want) {
t.Errorf("GET %s 页面缺少特征字段 %s", c.path, c.want)
}
}
// Tomcat Manager 真实未认证行为是 401 + Basic,给 HTML 表单反而不像。
tomcat, err := http.Get(ts.URL + "/manager/html")
if err != nil {
t.Fatal(err)
}
defer tomcat.Body.Close()
if tomcat.StatusCode != http.StatusUnauthorized {
t.Errorf("GET /manager/html status = %d, want 401", tomcat.StatusCode)
}
if !strings.Contains(tomcat.Header.Get("WWW-Authenticate"), "Tomcat") {
t.Errorf("WWW-Authenticate = %q, want 含 Tomcat", tomcat.Header.Get("WWW-Authenticate"))
}
}
// TestHoneypotCapturesFamilyFields 验证各家族的字段名都能被提取成凭据。
//
// 页面换了字段名但提取逻辑没跟上,等于做了个好看的空壳。
func TestHoneypotCapturesFamilyFields(t *testing.T) {
cases := []struct {
path, body, wantUser, wantPass string
}{
{"/phpmyadmin/index.php", "pma_username=root&pma_password=toor123", "root", "toor123"},
{"/jenkins/login", "j_username=admin&j_password=jenkins1", "admin", "jenkins1"},
{"/cgi-bin/luci", "luci_username=root&luci_password=openwrt", "root", "openwrt"},
{"/users/sign_in", "user%5Blogin%5D=gitlab&user%5Bpassword%5D=glpass", "gitlab", "glpass"},
}
for _, c := range cases {
cs, err := honeypot.NewFileCredStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
ts := newHoneypotTestServerFull(t, true, false, cs)
resp, err := http.Post(ts.URL+c.path, "application/x-www-form-urlencoded", strings.NewReader(c.body))
if err != nil {
t.Fatalf("POST %s: %v", c.path, 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("%s: 凭据记录数 = %d, want 1", c.path, len(recs))
}
if recs[0].Username != c.wantUser || recs[0].Password != c.wantPass {
t.Errorf("%s: 提取到 %q/%q, want %q/%q",
c.path, recs[0].Username, recs[0].Password, c.wantUser, c.wantPass)
}
}
}
// TestHoneypotAssetRequestsAreNotRecorded 验证静态资源请求只回占位内容、不落库。
//
// 真实后台页面会加载 5–10 个 CSS/JS/图标;如果每个都 INSERT,一次页面加载就能
// 放大出十几条记录,而 honeypot_hits 的写入完全由外部流量驱动 ——
// 等于把攻击者的写入成本转嫁给我们。
func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
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,
})
hph := newHoneypotHandler(hp, ab, store, nil)
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)
defer ts.Close()
for _, p := range []string{
"/wp-admin/css/login.min.css",
"/wp-includes/js/wp-embed.min.js",
"/wp-admin/images/wordpress-logo.png",
} {
resp, err := http.Get(ts.URL + p)
if err != nil {
t.Fatalf("GET %s: %v", p, err)
}
_, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", p, resp.StatusCode)
continue
}
if ct := resp.Header.Get("Content-Type"); ct == "" || strings.HasPrefix(ct, "text/html") {
t.Errorf("GET %s Content-Type = %q, want 非 HTML 的静态类型", p, ct)
}
}
if len(store.hits) != 0 {
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
}
}