线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是 `2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己 仍能正常访问,看日志也无从判断到底有没有封。 根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值): - 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账; - 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。 改动: - Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64): DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者; - autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus), route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at; 中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏); - 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示), 顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了; - honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把 排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。 测试(每项都做了变异验证:改坏→确认变红→恢复): - TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、 /64 才是封禁键、同 /64 换地址仍 403; - TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调; - TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复; - TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
57 lines
2.3 KiB
Go
57 lines
2.3 KiB
Go
package route
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/flamego/flamego"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
|
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
|
"github.com/tamakyi/TamaBox/internal/shared"
|
|
)
|
|
|
|
// autobanMiddleware 自动封禁中间件:挂载在静态黑名单之后、业务路由之前。
|
|
// - 已封禁 IP 直接 403,不再进入业务
|
|
// - 敏感路径命中 → 计入窗口统计(达到阈值触发临时封禁 + 邮件告警)
|
|
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
|
|
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
|
|
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
|
|
func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler {
|
|
return func(ctx flamego.Context) {
|
|
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
|
|
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
|
|
// 记录与判定必须使用同一个键,否则封禁将无法命中。
|
|
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
|
|
if ip == "" {
|
|
return
|
|
}
|
|
path := ctx.Request().URL.Path
|
|
if b.IsBanned(ip) {
|
|
// Debug 级别:被封住的扫描器会持续请求,Info 会让日志彻底淹没。
|
|
// 需要现场确认「封禁真的在拦」时把日志级别调到 debug 即可。
|
|
logrus.WithFields(logrus.Fields{
|
|
"ban_key": ip,
|
|
"path": path,
|
|
}).Debug("拒绝已封禁来源")
|
|
ctx.ResponseWriter().WriteHeader(http.StatusForbidden)
|
|
_, _ = ctx.ResponseWriter().Write([]byte("403 Forbidden"))
|
|
return
|
|
}
|
|
|
|
kind := autoban.KindNone
|
|
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
|
|
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
|
|
if hp == nil || !hp.Match(path) {
|
|
if autoban.IsSensitivePath(path) {
|
|
kind = autoban.KindSensitive
|
|
}
|
|
}
|
|
if kind != autoban.KindNone {
|
|
defer b.Record(ip, path, kind)
|
|
}
|
|
// 让下游业务路由先执行,敏感路径的计数延后到响应返回后记录。
|
|
ctx.Next()
|
|
}
|
|
}
|