Files
tamabox/internal/route/honeypot.go
T
tamakyi 846bb8a3d9 fix(honeypot): 清洗反代链路的脏路径,回环来源 IP 打配置告警
测试环境实测(Cloudflare → nginx → 源站)发现两处:
1. 凭据文件里记录的 path 是 " /wp-login.php"(前导空格);
2. 来源 IP 记成 127.0.0.1。

path:Match 内部会 TrimSpace 所以仍能命中,但落库与假表单 action 用的是原始
req.URL.Path —— 取证数据带脏字符,爆破脚本还可能把表单提交到错误路径。
新增 cleanPath(trim + 补前导斜杠,不改大小写) 在 Serve 入口清洗一次。

IP:记录到回环地址说明站点在反代后面但 [security] trust_proxy 未开启。
此时所有访客在源站眼里是同一个 IP:取证数据失去意义,且一次蜜罐封禁会
连带封掉整站(含普通访客)。新增一次性 WARN(24h 冷却),只提示不改行为 ——
本地直连调试时回环 IP 属正常。sample.ini 的 trust_proxy 段补对应说明。

测试:TestCleanPath / TestIsLoopbackIP 覆盖前导空格、制表符、空串、
无前导斜杠、大小写保持,以及 shared.LimitKey 折叠后的 "::/64" 形式。
2026-10-21 15:54:39 +08:00

396 lines
15 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"
"strings"
"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))
// 反代/网关可能把 URI 带上首尾空白:实测 Cloudflare → nginx → 源站 链路上
// 出现过 ` /wp-login.php`。Match 内部会 normalize(含 TrimSpace),所以能命中,
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
// 甚至让爆破脚本把表单提交到错误路径。这里统一清洗一次。
path := cleanPath(req.URL.Path)
// 来源 IP 是回环地址 + 未开 trust_proxy,是「站点在反代后面但没配信任」的
// 典型信号。此时所有访客在源站眼里都是同一个 IP:
// 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。
// 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。
if ip != "" && !conf.Security.TrustProxy && isLoopbackIP(ip) {
if h.dedup.first("warn:loopback", 24*time.Hour) {
logrus.Warn("蜜罐记录的来源 IP 是回环地址(127.0.0.1 / ::1):站点很可能在反向代理后面," +
"[security] trust_proxy 未开启。此时所有访客共享同一个 IP —— 取证失去意义," +
"且一次封禁会连带封掉整站。请配置 trust_proxy = true 与 trusted_proxies。")
}
}
// 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)
}
// cleanPath 去掉路径首尾空白并保证前导斜杠。
//
// 刻意不改大小写:URL 路径是大小写敏感的,取证必须保留原始形态。
func cleanPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
// isLoopbackIP 判断归一化后的限流键是否指向本机回环地址。
//
// 不用 net.ParseIP:shared.LimitKey 会把 IPv6 改写成 "xxxx::/64" 这种带前缀长度的
// 形式,ParseIP 解析不了;这里按语义做前缀判断即可。
func isLoopbackIP(ip string) bool {
host := ip
if i := strings.Index(host, "/"); i >= 0 {
host = host[:i]
}
if host == "::" || host == "::1" {
return true
}
return strings.HasPrefix(host, "127.")
}
// 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])
}