一、封禁链路整条没接上(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。
350 lines
13 KiB
Go
350 lines
13 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"
|
||
"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])
|
||
}
|