收口三个匿名可读端点: - 根路径 /metrics:默认完全不注册(访问 404);配置 METRICS_ALLOW_CIDR 后按内网白名单开放 - /health:改为鉴权(原本匿名泄漏版本、commit、运行时长、内存、goroutine 与配置错误文本) - /dashboard:改为鉴权(原本匿名,且它同时聚合 /health 与 /metrics,是单点泄漏最严重的端点) - /api/setup/status:改为鉴权(原本 normal 模式下仍匿名返回 installed/mode) 新增 /healthz 匿名存活探针,只回 200 与字面量 ok、不含任何字段,解决 "给 /health 加鉴权后 K8s 探针与 Docker HEALTHCHECK 会一律 401 导致 Pod 反复重启" 的问题; 新增 /api/admin/health 鉴权版详细健康数据,与 /api/admin/metrics 风格一致。 /dashboard 对浏览器 HTML 请求做内容协商:返回页面外壳由前端显示登录视图(SPA 登录态存在 sessionStorage、不随请求发送,服务端无从判断,强行 401 会把现有体验变成直接报错); 非 HTML 请求(脚本、抓取)无凭证一律 401。页面外壳本身不含数据,数据全部来自鉴权后的 API。 IP 白名单中间件复用 GetClientIP(已处理 XFF 与 TRUSTED_PROXY_HOPS),非白名单返回 404 而非 403,避免向扫描者确认端点存在。main.go 启动日志同步改为指向 /healthz 与 /api/admin/health。 验证(真实服务器端到端,非仅单元测试): - 安装前 /healthz=200、/health=401 - 安装后未带凭证:/healthz=200 /health=401 /metrics=404 /dashboard=401 /api/setup/status=401 - /dashboard 带 Accept: text/html 得 200 页面外壳,Accept: application/json 得 401 - /healthz 响应体确认为字面量 ok(不含任何字段) - 回退模式(未配 admin_key):业务 key 访问管理接口全部 200,向后兼容成立 - 配置独立 admin_key 后:业务 key 访问 /health /api/admin/* /api/voices /api/settings 全部 401, admin_key 全部 200,而 /v1/audio/speech 不受影响;GET /api/settings 只回打码值 另新增 middleware/metricsip_test.go 覆盖 CIDR 命中、越界、非法 IP、空列表拒绝, 以及伪造 X-Forwarded-For 不能绕过白名单。 go build ./... / go vet ./... / go test ./... -count=1 全绿。
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// metricsAllowList 是 METRICS_ALLOW_CIDR 解析出的内网白名单。
|
||||
// 启动期由 InitMetricsAllowList 填充;运行期只读,无并发写。
|
||||
var metricsAllowList []*net.IPNet
|
||||
|
||||
// metricsAllowConfigured 表示是否配置了非空的 METRICS_ALLOW_CIDR。
|
||||
// 决定根路径 /metrics、/health 是否注册(未配置则完全不注册,访问 404)。
|
||||
var metricsAllowConfigured bool
|
||||
|
||||
// InitMetricsAllowList 解析 METRICS_ALLOW_CIDR,启动期调用一次。
|
||||
//
|
||||
// 格式:逗号分隔的 CIDR 列表,例如 "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"。
|
||||
// 也接受裸 IP(自动补 /32 或 /128),方便写 "127.0.0.1"。
|
||||
//
|
||||
// 为什么需要它:根路径 /metrics、/health 是给 Prometheus 抓取 / K8s 探针用的机器端点,
|
||||
// 让它们带 Bearer 会强制改抓取配置;用内网白名单更省事,且默认不暴露。
|
||||
//
|
||||
// 【重要】Docker 环境不要填 127.0.0.1/32 —— 那是容器自身的回环,
|
||||
// Prometheus 在宿主机或另一个容器里根本进不来。请填容器内网网段(如 172.16.0.0/12)。
|
||||
func InitMetricsAllowList() {
|
||||
raw := strings.TrimSpace(os.Getenv("METRICS_ALLOW_CIDR"))
|
||||
metricsAllowList = nil
|
||||
metricsAllowConfigured = false
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// 裸 IP 自动补全掩码
|
||||
if !strings.Contains(entry, "/") {
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
entry += "/32"
|
||||
} else {
|
||||
entry += "/128"
|
||||
}
|
||||
}
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(entry)
|
||||
if err != nil {
|
||||
log.Printf("[metrics-ip] 忽略非法 CIDR 条目 %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
metricsAllowList = append(metricsAllowList, ipnet)
|
||||
}
|
||||
metricsAllowConfigured = len(metricsAllowList) > 0
|
||||
if metricsAllowConfigured {
|
||||
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 已启用,根路径 /metrics 仅对 %d 个网段开放", len(metricsAllowList))
|
||||
} else {
|
||||
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 无有效条目,根路径 /metrics 将不注册")
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAllowListConfigured 报告是否配置了有效的白名单网段。
|
||||
// router 据此决定是否注册根路径 /metrics / /health。
|
||||
func MetricsAllowListConfigured() bool { return metricsAllowConfigured }
|
||||
|
||||
// MetricsIPAllowList 只放行来源 IP 命中白名单的请求。
|
||||
//
|
||||
// 未命中返回 **404**(而不是 403):不向扫描者确认"这里存在一个只是你没权限的端点"。
|
||||
// 客户端 IP 取自 GetClientIP,它已处理 X-Forwarded-For 与 TRUSTED_PROXY_HOPS,
|
||||
// 所以反代后面的真实来源也能正确判定。
|
||||
func MetricsIPAllowList(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !ipAllowed(GetClientIP(r)) {
|
||||
// 不打印每个被拒请求,避免扫描流量刷爆日志;只按需在 debug 下输出
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ipAllowed 判定客户端 IP 是否命中任一白名单网段。
|
||||
// 空 IP(解析失败)一律拒绝 —— 宁可拒绝也不能误放行。
|
||||
func ipAllowed(clientIP string) bool {
|
||||
if clientIP == "" {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(clientIP))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range metricsAllowList {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// allowListFrom 把逗号分隔的 CIDR 串解析成白名单,供测试直接注入。
|
||||
func allowListFrom(t *testing.T, spec string) []*net.IPNet {
|
||||
t.Helper()
|
||||
var out []*net.IPNet
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
_, n, err := net.ParseCIDR(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("测试用例里的 CIDR 非法 %q: %v", entry, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reqFrom 构造带指定来源地址的请求。
|
||||
func reqFrom(remoteAddr string) *http.Request {
|
||||
r := httptest.NewRequest("GET", "/metrics", nil)
|
||||
r.RemoteAddr = remoteAddr
|
||||
return r
|
||||
}
|
||||
|
||||
// TestIPAllowed 验证 CIDR 白名单判定:
|
||||
// 命中放行、未命中拒绝,解析失败的 IP 一律拒绝(宁可拒绝也不误放行)。
|
||||
func TestIPAllowed(t *testing.T) {
|
||||
// 直接构造白名单,不依赖环境变量
|
||||
metricsAllowList = allowListFrom(t, "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,::1/128")
|
||||
metricsAllowConfigured = true
|
||||
t.Cleanup(func() {
|
||||
metricsAllowList = nil
|
||||
metricsAllowConfigured = false
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"本机 IPv4 命中 /32", "127.0.0.1", true},
|
||||
{"10.x 命中 /8", "10.1.2.3", true},
|
||||
{"172.16.x 命中 /12", "172.16.5.9", true},
|
||||
{"172.31.x 仍在 /12 内", "172.31.255.254", true},
|
||||
{"IPv6 回环命中 /128", "::1", true},
|
||||
{"公网 IP 不在白名单", "8.8.8.8", false},
|
||||
{"172.32.x 超出 /12 范围", "172.32.0.1", false},
|
||||
{"192.168.x 未配置", "192.168.1.1", false},
|
||||
{"空 IP 拒绝", "", false},
|
||||
{"非法 IP 拒绝", "not-an-ip", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := ipAllowed(c.ip); got != c.want {
|
||||
t.Errorf("ipAllowed(%q) = %v, want %v", c.ip, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPAllowed_EmptyListRejectsAll 白名单为空时全部拒绝(未配置 = 不开放)。
|
||||
func TestIPAllowed_EmptyListRejectsAll(t *testing.T) {
|
||||
metricsAllowList = nil
|
||||
metricsAllowConfigured = false
|
||||
t.Cleanup(func() {
|
||||
metricsAllowList = nil
|
||||
metricsAllowConfigured = false
|
||||
})
|
||||
|
||||
if ipAllowed("127.0.0.1") {
|
||||
t.Error("白名单为空时应拒绝所有 IP")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsIPAllowList_Scope 白名单内放行、白名单外 404。
|
||||
// 用 404 而不是 403,是为了不向扫描者确认"该端点存在,只是你没权限"。
|
||||
func TestMetricsIPAllowList_Scope(t *testing.T) {
|
||||
metricsAllowList = allowListFrom(t, "10.0.0.0/8")
|
||||
t.Cleanup(func() { metricsAllowList = nil })
|
||||
|
||||
h := MetricsIPAllowList(okHandler())
|
||||
|
||||
// 命中白名单 → 放行到 next
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, reqFrom("10.1.1.1:1234"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("白名单内来源: code=%d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// 白名单外(RFC 5737 文档地址)→ 404,且不应触达 next
|
||||
w2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(w2, reqFrom("192.0.2.1:1234"))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Errorf("白名单外来源: code=%d, want 404", w2.Code)
|
||||
}
|
||||
if w2.Body.String() == "ok" {
|
||||
t.Error("白名单外来源不应触达被保护的 handler")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsIPAllowList_XForwardedForSpoof 伪造 X-Forwarded-For 不能绕过白名单。
|
||||
//
|
||||
// GetClientIP 的启发式模式(trustedProxyHops==0,默认)从 XFF 链**尾部**取第一个
|
||||
// **公网** IP,刻意跳过私网跳 —— 这样攻击者无法用 "X-Forwarded-For: <内网IP>"
|
||||
// 把自己伪装成白名单来源。本测试锁死这个安全属性。
|
||||
func TestMetricsIPAllowList_XForwardedForSpoof(t *testing.T) {
|
||||
metricsAllowList = allowListFrom(t, "10.0.0.0/8")
|
||||
t.Cleanup(func() { metricsAllowList = nil })
|
||||
|
||||
h := MetricsIPAllowList(okHandler())
|
||||
|
||||
// 直连是私网(像反代),XFF 里塞一个内网 IP 想混进白名单
|
||||
r := reqFrom("127.0.0.1:1234")
|
||||
r.Header.Set("X-Forwarded-For", "10.9.9.9")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
// 启发式模式会跳过私网跳、落到直连地址 127.0.0.1(不在 10.0.0.0/8)→ 404。
|
||||
// 关键断言:伪造的内网 XFF **没有**让它通过。
|
||||
if w.Code == http.StatusOK {
|
||||
t.Errorf("伪造私网 XFF 不应绕过白名单: code=%d", w.Code)
|
||||
}
|
||||
|
||||
// 反向对照:XFF 填公网 IP 时,GetClientIP 会采用它,同样不在白名单 → 404
|
||||
r2 := reqFrom("127.0.0.1:1234")
|
||||
r2.Header.Set("X-Forwarded-For", "8.8.8.8")
|
||||
w2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(w2, r2)
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Errorf("公网来源不在白名单: code=%d, want 404", w2.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user