被封禁来源访问任何路径时,原来只回 13 字节的 "403 Forbidden":既没有信息量, 人肉访问(尤其是共享出口被连累的误封)时看着像站点挂了;扫描/爆破脚本也常靠 读这行明文确认「自己已被拦」。 改为返回一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),状态码不变: - 图片用 //go:embed 编进二进制,不依赖外部 URL、不占公开路径; - 显式给 Content-Length(8 KB 超过 Go 的 2 KB 写缓冲,不设会退回 chunked); - Cache-Control: no-store —— 中间层缓存 403 没有意义,还会让解封后的响应继续命中; - HEAD 只回头不写 body; - 放在 internal/route/assets/ 而不是 static/:static 下的东西有可公开访问的 URL, 等于多一个指纹入口。 注意链接:给的 R2 预签名地址 X-Amz-Expires=604800(7 天),热链到期就会挂, 所以是把字节取回来打包,而不是重定向或代理。 测试: - TestWriteBanned:状态码 403、image/webp、Content-Length 与内嵌字节数一致、 body 前 12 字节是 RIFF/WEBP(防止被换成 HTML 或明文这类回归); - TestWriteBannedHead:HEAD 无 body 但有 Content-Length; - 原 TestHoneypotBansEvenWhenAutoBanDisabled 里断言 "403 Forbidden" 明文的用例 改为断言 image/webp 与体积。 变异验证:把 writeBanned 改回写明文 + text/plain,两条用例均按预期变红。
54 lines
2.2 KiB
Go
54 lines
2.2 KiB
Go
package route
|
|
|
|
import (
|
|
"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("拒绝已封禁来源")
|
|
writeBanned(ctx.ResponseWriter(), ctx.Request().Request.Method)
|
|
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()
|
|
}
|
|
}
|