package main import ( "os" "strings" "testing" ) // 本文件把审计第 2 条钉死:**只有显式勾选 insecureTls 的上游**才输出 // `proxy_ssl_verify off;`。 // // 原缺陷写法是 `if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://")`, // 等于对**所有** https 上游无条件关闭上游证书校验 —— 中间人可冒充任意 https 后端, // 用户提交的凭据与 cookie 全部可被截获。 // // 行为变化(有意为之,别改回去):普通 https 上游现在走 nginx 默认,即校验上游证书 // 并使用系统 CA 库。公共 CA(Let's Encrypt 等)签发的上游不受影响; // **自签证书的上游会 502**,要接自签就必须在路由上勾 insecureTls。 const sslVerifyOff = "proxy_ssl_verify off;" // countOccurrences 数某个子串在整份配置里出现多少次。 func countOccurrences(hay, needle string) int { return strings.Count(hay, needle) } // proxyBlockOf 取出某个域名所属的 server 块。 func proxyBlockOf(t *testing.T, conf, host string) string { t.Helper() for _, b := range strings.Split(conf, "server {") { if strings.Contains(b, "server_name "+host+";") { return b } } t.Fatalf("找不到 server_name %s 的 server 块:\n%s", host, conf) return "" } // faviconBlocksOf 取出该 server 块里所有 `location = /favicon.*` 的片段 // (/favicon.ico 与 /favicon.svg 两条,writeRouteServer 会各写一个)。 func faviconBlocksOf(t *testing.T, block string) []string { t.Helper() var out []string for _, line := range strings.Split(block, "\n") { if strings.Contains(line, "location = /favicon") { out = append(out, line) } } // 同块内紧跟其后的 proxy_ssl_verify 也要带上,才能断言「属于 favicon 段」 var res []string for _, b := range out { if i := indexOf(block, b); i >= 0 { rest := block[i:] if j := indexOf(rest, "\n }"); j >= 0 { res = append(res, rest[:j]) } } } return res } func renderTLSConf(t *testing.T, routes ...Route) string { t.Helper() out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: routes}) if err != nil { t.Fatalf("渲染失败: %v", err) } return out } // 用例 1(正向):https 上游 + InsecureTLS=false → 主 location 不得有 proxy_ssl_verify off。 func TestHTTPSUpstreamVerifiesCertByDefault(t *testing.T) { out := renderTLSConf(t, Route{Host: "secure.example", Upstream: "https://example.com"}) block := proxyBlockOf(t, out, "secure.example") if strings.Contains(block, sslVerifyOff) { t.Fatalf("普通 https 上游不得关闭证书校验(会被中间人截),"+ "实际 server 块:\n%s", block) } // 必须仍然 proxy_pass 到 https 上游(不是干脆不代理了) if !strings.Contains(block, "proxy_pass https://example.com;") { t.Fatalf("应保留 proxy_pass 到 https 上游:\n%s", block) } } // 用例 2(反向):同上游但显式勾了 insecureTls → 必须有 proxy_ssl_verify off。 func TestHTTPSUpstreamInsecureTLSOptIn(t *testing.T) { out := renderTLSConf(t, Route{Host: "selfsigned.example", Upstream: "https://example.com", InsecureTLS: true}) block := proxyBlockOf(t, out, "selfsigned.example") if !strings.Contains(block, sslVerifyOff) { t.Fatalf("显式勾选 insecureTls 时必须关闭校验(否则自签上游 502),"+ "实际 server 块:\n%s", block) } } // 用例 3:http 上游 —— 两种情况都不该出现 proxy_ssl_verify off // (对 http 上游写这行是无意义噪声,也说明判断条件又跑偏了)。 func TestHTTPUpstreamNeverDisablesSSLVerify(t *testing.T) { for _, insecure := range []bool{false, true} { out := renderTLSConf(t, Route{Host: "plain.example", Upstream: "http://127.0.0.1:5667", InsecureTLS: insecure}) block := proxyBlockOf(t, out, "plain.example") if strings.Contains(block, sslVerifyOff) { t.Fatalf("http 上游不该出现 proxy_ssl_verify off(insecureTls=%v),server 块:\n%s", insecure, block) } } } // 用例 4:favicon 段(writeRouteServer 里那条 `location = /favicon.ico`) // 与主 location 行为一致 —— 防止只改了 writeProxyLocation 而漏掉 favicon 段。 func TestFaviconPassthroughFollowsSameTLSRule(t *testing.T) { // 4a:https 上游、未勾 insecureTls → favicon 段也不得关校验 out := renderTLSConf(t, Route{Host: "secure.example", Upstream: "https://example.com"}) block := proxyBlockOf(t, out, "secure.example") fav := faviconBlocksOf(t, block) if len(fav) == 0 { t.Fatalf("代理块应有 favicon 直通 location:\n%s", block) } for _, f := range fav { if strings.Contains(f, sslVerifyOff) { t.Fatalf("普通 https 上游的 favicon 段也不得关闭证书校验:\n%s", f) } if !strings.Contains(f, "proxy_pass https://example.com;") { t.Fatalf("favicon 段应直通上游:\n%s", f) } } // 4b:勾了 insecureTls → favicon 段必须也关校验(否则标签图标抓不到, // 表现为「站点图标丢失」,实测会让人误以为只有图标坏了) out = renderTLSConf(t, Route{Host: "selfsigned.example", Upstream: "https://example.com", InsecureTLS: true}) block = proxyBlockOf(t, out, "selfsigned.example") fav = faviconBlocksOf(t, block) if len(fav) == 0 { t.Fatalf("代理块应有 favicon 直通 location:\n%s", block) } for _, f := range fav { if !strings.Contains(f, sslVerifyOff) { t.Fatalf("勾了 insecureTls 时 favicon 段也必须关闭校验(否则图标抓不到):\n%s", f) } } } // 用例 5:计数断言 —— 整份配置里 proxy_ssl_verify off 的出现次数 // 必须等于「勾了 insecureTls 的 https 路由数」(每条路由贡献主 location + 2 个 favicon 段)。 // 这条能兜住「别处还有同类短路」:只要有任何一段无条件关闭校验,计数就会偏大。 func TestSSLVerifyOffCountMatchesInsecureRoutes(t *testing.T) { routes := []Route{ // 普通 https 上游:0 处 {Host: "secure.example", Upstream: "https://example.com"}, // 自签 https 上游:主 location + favicon.ico + favicon.svg = 3 处 {Host: "selfsigned.example", Upstream: "https://192.0.2.10:5667", InsecureTLS: true}, // http 上游:0 处(即使勾了 insecureTls 也不写) {Host: "plain.example", Upstream: "http://127.0.0.1:8080"}, {Host: "plain-insecure.example", Upstream: "http://127.0.0.1:8081", InsecureTLS: true}, // 带 bypassPaths + websocket 的自签路由:bypass 段与主 location 各 1 处,favicon 2 处 {Host: "guarded.example", Upstream: "https://192.0.2.20:5667", InsecureTLS: true, RequireLogin: true, Websocket: true, BypassPaths: []string{"/search"}}, // 门户类:无上游,0 处 {Host: "portal.example", Kind: "portal"}, } // 计数用例需要认证地址/登录地址才能渲染受保护路由 out, err := RenderGatewayConf(&GatewayConfig{ ListenPort: 8081, AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.example/login", Routes: routes, }) if err != nil { t.Fatalf("渲染失败: %v", err) } // https + insecureTls 的路由:selfsigned(1 主 + 2 favicon)、guarded(1 主 + 1 bypass + 2 favicon) want := 3 + 4 if got := countOccurrences(out, sslVerifyOff); got != want { t.Fatalf("proxy_ssl_verify off 共出现 %d 次,期望 %d 次"+ "(= 勾了 insecureTls 的 https 路由各自的主/bypass location + favicon 段);"+ "次数偏大通常意味着又出现了「https 就无条件关校验」的短路。\n完整配置:\n%s", got, want, out) } // 逐块核对:普通 https 块的计数必须是 0 if n := countOccurrences(proxyBlockOf(t, out, "secure.example"), sslVerifyOff); n != 0 { t.Fatalf("secure.example 不该有任何 proxy_ssl_verify off,实际 %d 次", n) } if n := countOccurrences(proxyBlockOf(t, out, "guarded.example"), sslVerifyOff); n != 4 { t.Fatalf("guarded.example(主 + bypass + 2 个 favicon)应恰好 4 次,实际 %d 次", n) } } // 用例 6:行为变化写进文档/注释 —— 自签上游默认 502 是预期, // 只允许通过 insecureTls 显式放开。这条防止有人「顺手把默认改回去」还删掉说明。 func TestInsecureTLSBehaviourIsDocumentedInSource(t *testing.T) { for _, f := range []string{"gateway.go"} { src, err := os.ReadFile(f) if err != nil { t.Fatalf("读取 %s 失败: %v", f, err) } s := string(src) if strings.Contains(s, `r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://")`) { t.Fatalf("%s 里仍有「https 就无条件关校验」的写法,必须改成只看 InsecureTLS", f) } if strings.Contains(s, `r.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://")`) { t.Fatalf("%s 的 favicon 段里仍有「https 就无条件关校验」的写法", f) } if !strings.Contains(s, "自签") || !strings.Contains(s, "502") { t.Fatalf("%s 应写明「自签上游默认 502 是预期,要自签就勾 insecureTls」", f) } } }