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" 形式。
This commit is contained in:
@@ -55,6 +55,9 @@ trust_proxy = false
|
|||||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||||
|
; ⚠️ 蜜罐取证同理:trust_proxy = false 时所有请求的来源都被记成反代地址
|
||||||
|
; (常见 127.0.0.1),取证数据失去意义;而且一次蜜罐封禁会连带封掉整站。
|
||||||
|
; 蜜罐命中回环地址时启动日志会打一次告警提醒检查这里。
|
||||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package route
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -129,7 +130,23 @@ func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.Hone
|
|||||||
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||||
req := ctx.Request().Request
|
req := ctx.Request().Request
|
||||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
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),再决定封禁与响应。
|
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
|
||||||
creds := h.hp.ExtractCredentials(req)
|
creds := h.hp.ExtractCredentials(req)
|
||||||
@@ -167,6 +184,35 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
h.record(ip, req, path, creds, banned)
|
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 返回假登录页。
|
// serveLoginPage 返回假登录页。
|
||||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||||
h.writeLogin(ctx, path, "")
|
h.writeLogin(ctx, path, "")
|
||||||
|
|||||||
@@ -74,6 +74,47 @@ func newHoneypotTestServerFull(t *testing.T, enabled, autoBan bool, cs honeypot.
|
|||||||
return ts
|
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),
|
// TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary),
|
||||||
// 而不是 302 跳首页。
|
// 而不是 302 跳首页。
|
||||||
func TestHoneypotServesFakeLoginPage(t *testing.T) {
|
func TestHoneypotServesFakeLoginPage(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user