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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user