diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 65b4fbf..e0543c1 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -55,6 +55,9 @@ trust_proxy = false ; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。 ; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true, ; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。 + ; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址 + ; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。 + ; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。 ; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull / ; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造 ; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。 diff --git a/internal/route/honeypot.go b/internal/route/honeypot.go index e5325ce..6acfbe8 100644 --- a/internal/route/honeypot.go +++ b/internal/route/honeypot.go @@ -7,6 +7,7 @@ package route import ( "context" "net/http" + "strings" "sync" "time" @@ -129,7 +130,23 @@ func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.Hone func (h *honeypotHandler) Serve(ctx flamego.Context) { req := ctx.Request().Request ip := shared.LimitKey(shared.ResolveClientIP(req)) - path := req.URL.Path + // 反代/网关可能把 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) @@ -167,6 +184,35 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) { 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, "") diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go index 5a66f58..b36eab1 100644 --- a/internal/route/honeypot_test.go +++ b/internal/route/honeypot_test.go @@ -74,6 +74,47 @@ func newHoneypotTestServerFull(t *testing.T, enabled, autoBan bool, cs honeypot. return ts } +// TestCleanPath 覆盖反代链路可能带来的脏路径。 +// +// 实测 Cloudflare → nginx → 源站 上出现过 " /wp-login.php"(前导空格)。 +// Match 内部会 TrimSpace 所以仍能命中,但落库值与假表单 action 用的是原始值: +// 取证数据变脏,爆破脚本还可能把表单提交到错误路径。 +func TestCleanPath(t *testing.T) { + cases := map[string]string{ + "/wp-login.php": "/wp-login.php", + " /wp-login.php": "/wp-login.php", + "\t/wp-login.php": "/wp-login.php", + "wp-login.php": "/wp-login.php", + "": "/", + " ": "/", + "/ADMIN": "/ADMIN", // 不改大小写:URL 路径大小写敏感 + } + for in, want := range cases { + if got := cleanPath(in); got != want { + t.Errorf("cleanPath(%q) = %q, want %q", in, got, want) + } + } +} + +// TestIsLoopbackIP 覆盖回环判定(含 LimitKey 折叠后的 IPv6 形式)。 +func TestIsLoopbackIP(t *testing.T) { + cases := map[string]bool{ + "127.0.0.1": true, + "127.1.2.3": true, + "::1": true, + "::": true, + "::/64": true, // shared.LimitKey 折叠 IPv6 后的形式 + "8.8.8.8": false, + "203.0.113.9": false, + "": false, + } + for ip, want := range cases { + if got := isLoopbackIP(ip); got != want { + t.Errorf("isLoopbackIP(%q) = %v, want %v", ip, got, want) + } + } +} + // TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary), // 而不是 302 跳首页。 func TestHoneypotServesFakeLoginPage(t *testing.T) {