以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
749 lines
26 KiB
Go
749 lines
26 KiB
Go
// 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)
|
||
}
|
||
// 没配 ban_image_path 时,封禁响应就是纯文字(见 writeBanned)。
|
||
// 配了图的分支由 banned_test.go 覆盖。
|
||
if ct := resp2.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||
}
|
||
if !strings.Contains(string(body), banText) {
|
||
t.Errorf("body = %q, want 含 %q", truncate(string(body), 80), banText)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 周期报告用到的时间窗口聚合。这组用例不测它们(报告有自己的单测),
|
||
// 但接口扩展后必须补上,否则整个包编译不过。
|
||
func (c *captureStore) AggregateSources(context.Context, time.Time, time.Time) ([]*db.HoneypotSourceAgg, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (c *captureStore) AggregatePaths(context.Context, time.Time, time.Time, int) ([]*db.HoneypotPathStat, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (c *captureStore) TimestampsBetween(context.Context, time.Time, time.Time) ([]time.Time, 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)
|
||
}
|
||
}
|