fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库

一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
  既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
  honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
This commit is contained in:
tamakyi
2026-10-21 15:43:25 +08:00
parent 566b13eedd
commit c29e1d071f
21 changed files with 1573 additions and 268 deletions
+101
View File
@@ -0,0 +1,101 @@
// 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 (
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
)
// 这组测试守的是「装配层」而不是「逻辑层」。
//
// 背景:蜜罐的封禁通道参数(HoneypotEnabled / HoneypotThreshold /
// HoneypotDuration)曾经在 route.New 里漏传,而 autoban.RecordHoneypot 第一行
// 就是 `if !b.honeypotEnabled { return false }` —— 表现为蜜罐命中永不封禁、
// 落库 banned 恒为 false,但所有测试全绿(它们都自建 Banner 并显式传了这些字段)。
//
// 因此这里的每个用例都必须经由 newBanner()(生产唯一装配入口),
// 绝不能自己构造 autoban.Options。
// swapSecurity 在测试期间覆盖 conf.Security,结束后恢复。
func swapSecurity(t *testing.T) {
t.Helper()
old := conf.Security
t.Cleanup(func() { conf.Security = old })
}
// TestNewBannerWiresHoneypotChannel 是这次漏接线的直接回归测试。
func TestNewBannerWiresHoneypotChannel(t *testing.T) {
swapSecurity(t)
conf.Security.EnableHoneypot = true
conf.Security.HoneypotThreshold = 1
conf.Security.HoneypotBanMinutes = 1440
b := newBanner()
if !b.RecordHoneypot("203.0.113.9", "/wp-login.php") {
t.Fatal("蜜罐开启时 RecordHoneypot 应判定封禁:newBanner 很可能漏传了 HoneypotEnabled")
}
if !b.IsBanned("203.0.113.9") {
t.Fatal("蜜罐命中后 IsBanned 应为 true")
}
}
// TestNewBannerHonorsHoneypotThreshold 验证阈值真的传进去了。
//
// 只断言「能封禁」是不够的:漏传 HoneypotThreshold 时 autoban 会兜底成 1,
// 一次就封,看起来一切正常。设成 3 才能区分「传了」与「用了默认值」。
func TestNewBannerHonorsHoneypotThreshold(t *testing.T) {
swapSecurity(t)
conf.Security.EnableHoneypot = true
conf.Security.HoneypotThreshold = 3
conf.Security.HoneypotBanMinutes = 60
b := newBanner()
for i := 1; i <= 2; i++ {
if b.RecordHoneypot("198.51.100.7", "/admin") {
t.Fatalf("第 %d 次命中不应封禁(阈值 3)——阈值未接线时会退化为默认 1", i)
}
}
if !b.RecordHoneypot("198.51.100.7", "/admin") {
t.Fatal("第 3 次命中应封禁(阈值 3)")
}
}
// TestNewBannerHoneypotIndependentOfAutoBan 守住「两个开关互不连坐」。
//
// 线上触发过的组合:运维嫌自动封禁误伤多,关掉 enable_auto_ban;
// 结果蜜罐跟着变成只记录不封禁的旁观者。
func TestNewBannerHoneypotIndependentOfAutoBan(t *testing.T) {
swapSecurity(t)
conf.Security.EnableAutoBan = false
conf.Security.EnableHoneypot = true
conf.Security.HoneypotThreshold = 1
conf.Security.HoneypotBanMinutes = 60
b := newBanner()
if !b.RecordHoneypot("192.0.2.5", "/.env") {
t.Fatal("enable_auto_ban=false 不应让蜜罐封禁失效")
}
if !b.IsBanned("192.0.2.5") {
t.Fatal("蜜罐封禁应在 enable_auto_ban=false 时依然生效")
}
}
// TestNewBannerHoneypotDisabled 守住反向:关掉蜜罐就不该凭空封人。
func TestNewBannerHoneypotDisabled(t *testing.T) {
swapSecurity(t)
conf.Security.EnableHoneypot = false
conf.Security.HoneypotThreshold = 1
conf.Security.HoneypotBanMinutes = 1440
b := newBanner()
if b.RecordHoneypot("192.0.2.9", "/wp-admin") {
t.Fatal("enable_honeypot=false 时不应封禁")
}
if b.IsBanned("192.0.2.9") {
t.Fatal("enable_honeypot=false 时不应产生封禁记录")
}
}
+179 -93
View File
@@ -6,9 +6,7 @@ package route
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
@@ -32,28 +30,98 @@ import (
// 行为:
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
// - 提交了凭据 → 立即封禁(RecordHoneypot,不受 enable_auto_ban 约束)+ 告警邮件;
// - 纯路径探测 → 只计入常规敏感路径阈值,不立即封禁。必须留出「咬钩」的机会:
// 爆破脚本先 GET 探表单再 POST 提交,GET 就把人封了等于永远拿不到凭据;
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
//
// 蜜罐取证的去重/采样参数。
const (
// alertDedupTTL 同一个 IP 的告警冷却时间。旧实现是「永久只发一次」,
// 直接让 map 随源 IP 数量无界增长;24 小时既能压住噪音,也允许
// 同一 IP 在长期攻击中再次提醒。
alertDedupTTL = 24 * time.Hour
// probeSampleWindow 纯路径探测(无凭据)的落库采样窗口:
// 同一 IP 在窗口内最多落一行,保留「谁扫了哪些路径」的分布,
// 但单个 IP 的洪泛不会变成等量 INSERT。
probeSampleWindow = time.Minute
// maxDedupEntries 去重表容量上限,超出后先清过期项、仍超则按容量硬裁。
maxDedupEntries = 20000
)
// deduper 是一个带过期时间的 best-effort 去重器。
//
// 用「带 TTL 的 map」而不是永不过期的 map[string]bool:
// 蜜罐的键完全由外部流量决定(源 IP),后者等于把内存交给攻击者控制。
type deduper struct {
mu sync.Mutex
seen map[string]time.Time
}
func newDeduper() *deduper {
return &deduper{seen: make(map[string]time.Time)}
}
// first 报告 key 是否在窗口内首次出现;首次会登记,过期时间为 now+ttl。
// ttl <= 0 表示永不过期(仍受容量上限约束)。
func (d *deduper) first(key string, ttl time.Duration) bool {
if key == "" {
return true
}
now := time.Now()
d.mu.Lock()
defer d.mu.Unlock()
if until, ok := d.seen[key]; ok {
if ttl <= 0 || until.After(now) {
return false
}
}
d.seen[key] = now.Add(ttl)
if len(d.seen) > maxDedupEntries {
d.pruneLocked(now)
}
return true
}
// pruneLocked 先清过期项;仍超上限时按容量硬裁到一半。
// 硬裁是 best-effort:宁可偶尔漏一次去重,也不能让内存无界增长。
func (d *deduper) pruneLocked(now time.Time) {
for k, until := range d.seen {
if !until.IsZero() && !until.After(now) {
delete(d.seen, k)
}
}
limit := maxDedupEntries / 2
for k := range d.seen {
if len(d.seen) <= limit {
break
}
delete(d.seen, k)
}
}
type honeypotHandler struct {
hp *honeypot.Honeypot
ab *autoban.Banner
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
store db.HoneypotStore
// cstore 是凭据取证文件的存储,允许为 nil(目录不可写时降级)。
// 用户名/密码只走这里,绝不写进 store(数据库)。
cstore honeypot.CredStore
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
sendAlert func(mail.HoneypotAlert) error
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
alerted map[string]bool
alertedMu sync.Mutex
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
dedup *deduper
}
// newHoneypotHandler 构造蜜罐处理器。
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
return &honeypotHandler{
hp: hp,
ab: ab,
store: store,
alerted: map[string]bool{},
hp: hp,
ab: ab,
store: store,
cstore: cstore,
dedup: newDeduper(),
}
}
@@ -63,26 +131,35 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
ip := shared.LimitKey(shared.ResolveClientIP(req))
path := req.URL.Path
// 封禁:走 autoban 的蜜罐独立通道(RecordHoneypot)。
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
creds := h.hp.ExtractCredentials(req)
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
//
// 刻意不用常规的 Record:它受 enable_auto_ban 开关约束,
// 而蜜罐是显式开启的功能 —— 开了就该封禁,不该被另一个开关连坐。
// 蜜罐路径与业务路由零交集,正常访客不可能命中,阈值取 1(一次即封)。
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
//
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
banned := false
if h.ab != nil && ip != "" {
banned = h.ab.RecordHoneypot(ip, path)
if creds.HadFields {
banned = h.ab.RecordHoneypot(ip, path)
} else {
h.ab.Record(ip, path, autoban.KindSensitive)
}
}
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
creds := h.hp.ExtractCredentials(req)
switch req.Method {
case http.MethodGet, http.MethodHead:
h.serveLoginPage(ctx, path)
case http.MethodPost, http.MethodPut, http.MethodPatch:
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
h.serveLoginFailure(ctx, path)
default:
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
}
@@ -92,71 +169,66 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
// serveLoginPage 返回假登录页。
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
redir := ""
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
redir = q
}
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
Action: path,
Title: "Log In ‹ WordPress",
Heading: "WordPress",
Redir: redir,
})
h.writeLogin(ctx, path, "")
}
// serveLoginFailure 返回「登录失败」的假响应。
//
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) {
h.writeLogin(ctx, path, honeypot.LoginFailureNotice)
}
// writeLogin 是两种响应形态的唯一出口。
//
// 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type /
// Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、
// 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。
func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
Action: path,
Title: "Log In \u2039 WordPress",
Title: honeypot.LoginPageTitle,
Heading: "WordPress",
Notice: notice,
Redir: ctx.Request().URL.Query().Get("redirect_to"),
})
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// record 追加一条命中记录。
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
//
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
// 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
h.recordCredentials(ip, req, path, creds, banned)
}
// recordCredentials 把一次命中写入数据库。
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
provider, proxied := ipinfo.Lookup(ip)
fieldsJSON := ""
if len(creds.Fields) > 0 {
if b, err := json.Marshal(creds.Fields); err == nil {
fieldsJSON = string(b)
}
if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) {
return
}
query := ""
if req.URL != nil {
query = req.URL.RawQuery
if len(query) > 1000 {
query = query[:1000]
}
}
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
if creds.HadFields {
h.saveCredential(ip, req, path, query, creds, banned)
}
provider, proxied := ipinfo.Lookup(ip)
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
// 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上
// 以 `value too long for type character varying(N)` 整条插入失败 ——
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
hit := &db.HoneypotHit{
IP: ip,
Method: req.Method,
Path: path,
Query: query,
Username: creds.Username,
Password: creds.Password,
Fields: fieldsJSON,
RawBody: creds.RawBody,
IP: truncate(ip, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
HadFields: creds.HadFields,
UserAgent: truncate(req.UserAgent(), 500),
Referer: truncate(req.Referer(), 500),
@@ -195,20 +267,56 @@ func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path s
}
}
// saveCredential 把一次凭据提交追加到取证文件。
//
// 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库,
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
// 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证,
// 也不能消除「凭据以某种形式留在库里」这个事实。
//
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
func (h *honeypotHandler) saveCredential(ip string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
if h.cstore == nil {
logrus.WithField("ip", ip).
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
return
}
rec := honeypot.CredRecord{
Time: time.Now(),
IP: truncate(ip, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
Username: truncate(creds.Username, 512),
Password: truncate(creds.Password, 512),
Fields: creds.Fields,
RawBody: creds.RawBody,
UserAgent: truncate(req.UserAgent(), 500),
Banned: banned,
}
if err := h.cstore.Append(rec); err != nil {
logrus.WithError(err).WithField("ip", ip).Warn("蜜罐凭据写入文件失败")
}
}
// alert 发送一次告警邮件(每个 IP 只发一次)。
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
if h.sendAlert == nil || ip == "" {
return
}
h.alertedMu.Lock()
if h.alerted[ip] {
h.alertedMu.Unlock()
// 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
// 不去重会把邮箱打爆(且每个源 IP 都是一封)。
if !h.dedup.first("alert:"+ip, alertDedupTTL) {
return
}
h.alerted[ip] = true
h.alertedMu.Unlock()
durationMin := conf.Security.BanDurationMinutes
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
// 否则运维按邮件判断「什么时候解封」会错。
durationMin := conf.Security.HoneypotBanMinutes
if durationMin <= 0 {
durationMin = conf.Security.BanDurationMinutes
}
if durationMin <= 0 {
durationMin = 15
}
@@ -239,25 +347,3 @@ func truncate(s string, n int) string {
}
return string(r[:n])
}
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
func honeypotExtraPaths(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if !strings.HasPrefix(f, "/") {
f = "/" + f
}
out = append(out, f)
}
return out
}
+87 -5
View File
@@ -30,6 +30,12 @@ func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
// 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})
@@ -40,7 +46,7 @@ func newHoneypotTestServerWith(t *testing.T, enabled, autoBan bool) *httptest.Se
Window: 60 * time.Second,
Duration: 60 * time.Minute, // ⚠️ 必须是 time.Duration:写 60 是 60 纳秒,封禁瞬间即失效
})
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
hph := newHoneypotHandler(hp, ab, nil, cs) // store=nil → 元数据不落库,只走日志
// 与 internal/route/route.go 一致:自动封禁中间件挂在全局 Use 链,
// 在业务路由与 NotFound 之前 —— 它负责检查「是否已封禁」并返回 403。
@@ -159,6 +165,38 @@ func TestHoneypotCapturesCredentials(t *testing.T) {
}
}
// 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)
@@ -190,14 +228,16 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
// 蜜罐开、常规自动封禁关 —— 这正是线上触发问题的组合
ts := newHoneypotTestServerWith(t, true, false)
// 第一次命中蜜罐
resp, err := http.Get(ts.URL + "/wp-login.php")
// 第一次:提交凭据(这才是触发蜜罐封禁的行为;纯 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)
t.Fatalf("首次提交 status = %d, want 200(应先返回假登录页)", resp.StatusCode)
}
// 第二次:应已被蜜罐封禁 → 403
@@ -215,11 +255,53 @@ func TestHoneypotBansEvenWhenAutoBanDisabled(t *testing.T) {
}
}
// 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.Get(ts.URL + "/wp-login.php")
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)
}
+42 -12
View File
@@ -73,16 +73,18 @@ func securityHeaders() flamego.Handler {
}
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
ab := autoban.New(autoban.Options{
// newBanner 按 conf.Security 装配自动封禁器。
//
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
//
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
// 下次再漏传会直接测试失败。
func newBanner() *autoban.Banner {
return autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
ThresholdSensitive: conf.Security.BanThresholdSensitive,
@@ -96,15 +98,43 @@ func New() *flamego.Flame {
provider, proxied := ipinfo.Lookup(ip)
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
},
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
HoneypotEnabled: conf.Security.EnableHoneypot,
HoneypotThreshold: conf.Security.HoneypotThreshold,
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
})
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
ab := newBanner()
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
hp := honeypot.New(honeypot.Options{
Enabled: conf.Security.EnableHoneypot,
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
})
hph := newHoneypotHandler(hp, ab, db.Honeypots)
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
// 绝不让取证功能把站点拖挂。
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
if err != nil {
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
credStore = nil
}
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {