Files
HomeDash/admin-server/gateway_tls_verify_test.go
JianFeeeee 5562d584d3 fix(gateway): https 上游不再无条件关闭证书校验
审计第 2 条:writeProxyLocation(主 location / bypass 段)与 writeRouteServer
的 favicon 段原本都是

    if r.InsecureTLS || strings.HasPrefix(strings.ToLower(upstream), "https://") {
        proxy_ssl_verify off;
    }

即「只要上游是 https 就关掉上游证书校验」,对所有 https 上游一律不验签 ——
中间人可冒充任意 https 后端,用户提交的凭据与 cookie 全部可截。

现在条件收敛为 `r.InsecureTLS && isHTTPSUpstream(upstream)`:
「上游是 https」不再是关闭校验的理由,只有**显式勾选 insecureTls** 的路由
才会输出 proxy_ssl_verify off。两处(writeProxyLocation 用 r.Upstream、
favicon 段用局部变量 up)都改到了,并抽出 isHTTPSUpstream 消除重复判断。

⚠️ 运维需知的行为变化(有意为之,不要偷偷改回去):
- 普通 https 上游现在走 nginx 默认:**校验上游证书**,使用系统 CA 库。
  公共 CA(Let's Encrypt 等)签发的上游不受影响,无需任何配置。
- 仓库内没有 proxy_ssl_trusted_certificate 的配置,因此**自签证书的上游现在会 502**。
  这是预期的 fail-closed 结果:要接自签上游,请在该路由上显式勾选 insecureTls
  (等价于确认「我知道这条链路不验签」)。
- http 上游行为不变(本来就不会有 TLS 握手);本次也顺带保证不会给它写这行噪声配置。

测试(admin-server/gateway_tls_verify_test.go,沿用 gateway_test.go 的
RenderGatewayConf + section 取值风格):
1. https 上游 + InsecureTLS=false → 主 location 不含 proxy_ssl_verify off;
2. https 上游 + InsecureTLS=true  → 含;
3. http 上游(两种情况)→ 都不含;
4. favicon 段(location = /favicon.ico 与 /favicon.svg)逐条断言 1/2 两种行为,
   防止只改主 location 漏掉 favicon 段;
5. 计数断言:整份配置里 proxy_ssl_verify off 的出现次数 == 勾了 insecureTls 的
   https 路由贡献的 location 数(主/bypass + 2 个 favicon),兜住别处同类短路;
6. 源码护栏:不得再出现两种旧写法,且注释必须保留「自签默认 502 是预期」的说明。

反向验证:把两处判断改回 `|| https://` 后,1/3/4/5/6 全部报红(详见
`go test -run 'TestHTTPSUpstream|TestHTTPUpstream|TestFaviconPassthrough|TestSSLVerifyOff|TestInsecureTLSBehaviour'`),
计数用例给出 13 次 vs 期望 7 次;恢复后全绿。
2026-10-22 23:14:32 +08:00

209 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}