Files
tamabox/internal/route/honeypot_test.go
T
tamakyi 307bd94ed1 feat(honeypot): /xmlrpc.php 走真实 XML-RPC,动态 token 不再恒定
线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装:

一、/xmlrpc.php 回的是 HTML 登录页(P1)
我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来,
一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。
更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、
且绕开登录页限速插件,是 WP 爆破里产出最高的入口。

新增 xmlrpc.go:
- 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致)
- POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
- POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
- 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除
  methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让
  配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进
  fields(xmlrpc.0/1/…),配对失败也不丢原文。
  multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串
  密码是常见字典形态,只留第一组等于丢掉整份字典。
- 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。

二、进入页面的隐藏值每次请求都一样(P2)
两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能
判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token,
不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级),
页头、hidden 与 Set-Cookie 共用同一套。

顺带:
- X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身
  就是破绽;没配 external_url 时干脆不发这个头。
- Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0
  (no-store 更像安全设备/代理加的,不像登录页)。
- Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。
- Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php,
  原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。

测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id /
非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且
是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、
所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 +
回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。
四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、
.php 兜底失效。

性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),
AssetFor 仍 0 分配。
2026-10-23 12:58:48 +08:00

730 lines
25 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))
}
}
// TestXMLRPCPostCapturesCredentials 守住 /xmlrpc.php 是真正的 XML-RPC 端点。
//
// 之前它回的是 HTML 登录页,而响应头 X-Pingback 又指着这个地址 ——
// 扫描器跟过来一看是 HTML 就知道整站是假的;更可惜的是 system.multicall
// 这条「一次上千组凭据」的通道被白白浪费。
func TestXMLRPCPostCapturesCredentials(t *testing.T) {
cs, err := honeypot.NewFileCredStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
ts := newHoneypotTestServerFull(t, true, false, cs)
body := `<methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><array><data>` +
`<value><string>http://127.0.0.1/xmlrpc.php</string></value>` +
`<value><string>root</string></value>` +
`<value><string>toor</string></value>` +
`</data></array></value></param></params></methodCall>`
resp, err := http.Post(ts.URL+"/xmlrpc.php", "text/xml", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200(真实 WordPress 用 200 + fault 表达登录失败)", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
t.Errorf("Content-Type = %q, want text/xml", ct)
}
if strings.Contains(string(payload), "<html") || strings.Contains(string(payload), honeypot.CanaryToken) {
t.Errorf("响应里出现了假登录页:%q", truncate(string(payload), 120))
}
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].Username != "root" || recs[0].Password != "toor" {
t.Errorf("凭据 = %q/%q, want root/toor", recs[0].Username, recs[0].Password)
}
if !recs[0].Banned {
t.Error("multicall 提交凭据应触发立即封禁")
}
// 封禁生效后,连普通 GET 也应被中间件挡掉。
resp2, err := http.Get(ts.URL + "/xmlrpc.php")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("封禁后 status = %d, want 403", resp2.StatusCode)
}
}
// TestXMLRPCGetReturnsFault 覆盖非 POST:真实 WordPress 回 405 + XML fault,
// 不是登录页。
func TestXMLRPCGetReturnsFault(t *testing.T) {
ts := newHoneypotTestServerFull(t, true, false, nil)
resp, err := http.Get(ts.URL + "/xmlrpc.php")
if err != nil {
t.Fatal(err)
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
t.Errorf("Content-Type = %q, want text/xml", ct)
}
if !strings.Contains(string(payload), honeypot.XMLRPCFaultDisabled) {
t.Errorf("body = %q, want 含 %q", truncate(string(payload), 120), honeypot.XMLRPCFaultDisabled)
}
}