Files
tamabox/internal/route/honeypot.go
T
tamakyi c29e1d071f 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。
2026-10-21 15:43:25 +08:00

350 lines
13 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"
"net/http"
"sync"
"time"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
)
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
//
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
//
// 行为:
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
// - 提交了凭据 → 立即封禁(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
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
dedup *deduper
}
// newHoneypotHandler 构造蜜罐处理器。
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
return &honeypotHandler{
hp: hp,
ab: ab,
store: store,
cstore: cstore,
dedup: newDeduper(),
}
}
// Serve 处理一次蜜罐命中。
func (h *honeypotHandler) Serve(ctx flamego.Context) {
req := ctx.Request().Request
ip := shared.LimitKey(shared.ResolveClientIP(req))
path := req.URL.Path
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
creds := h.hp.ExtractCredentials(req)
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
//
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
//
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
banned := false
if h.ab != nil && ip != "" {
if creds.HadFields {
banned = h.ab.RecordHoneypot(ip, path)
} else {
h.ab.Record(ip, path, autoban.KindSensitive)
}
}
switch req.Method {
case http.MethodGet, http.MethodHead:
h.serveLoginPage(ctx, path)
case http.MethodPost, http.MethodPut, http.MethodPatch:
h.serveLoginFailure(ctx, path)
default:
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
}
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
h.record(ip, req, path, creds, banned)
}
// serveLoginPage 返回假登录页。
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
h.writeLogin(ctx, path, "")
}
// serveLoginFailure 返回「登录失败」的假响应。
//
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
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: honeypot.LoginPageTitle,
Heading: "WordPress",
Notice: notice,
Redir: ctx.Request().URL.Query().Get("redirect_to"),
})
}
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
//
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
// 纯路径探测按 IP + 窗口采样即可 —— 路径分布仍然保留。
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
if !creds.HadFields && !h.dedup.first("probe:"+ip, probeSampleWindow) {
return
}
query := ""
if req.URL != nil {
query = req.URL.RawQuery
}
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
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: 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),
ContentType: truncate(req.Header.Get("Content-Type"), 250),
Provider: provider,
Proxied: proxied,
Banned: banned,
CreatedAt: time.Now(),
}
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
if creds.HadFields {
h.alert(ip, req, path, creds, provider, proxied, banned)
}
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
if creds.HadFields {
logrus.WithFields(logrus.Fields{
"ip": ip,
"path": path,
"username": creds.Username,
"banned": banned,
}).Warn("honeypot: credential submission captured")
}
if h.store == nil {
return
}
// 用独立超时上下文,避免请求被取消时取证写不进去。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := h.store.Create(ctx, hit); err != nil {
logrus.WithError(err).WithField("ip", ip).WithField("path", path).
Warn("honeypot: failed to persist hit")
}
}
// 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
}
// 同一 IP 在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
// 不去重会把邮箱打爆(且每个源 IP 都是一封)。
if !h.dedup.first("alert:"+ip, alertDedupTTL) {
return
}
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
// 否则运维按邮件判断「什么时候解封」会错。
durationMin := conf.Security.HoneypotBanMinutes
if durationMin <= 0 {
durationMin = conf.Security.BanDurationMinutes
}
if durationMin <= 0 {
durationMin = 15
}
go func() {
if err := h.sendAlert(mail.HoneypotAlert{
IP: ip,
Provider: provider,
Proxied: proxied,
Path: path,
Username: creds.Username,
Password: creds.Password,
Banned: banned,
DurationMin: durationMin,
}); err != nil {
logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail")
}
}()
}
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
func truncate(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}