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 次;恢复后全绿。
This commit is contained in:
@ -231,6 +231,10 @@ func writeErrorPages(b *strings.Builder, include404, portalFavicon bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPSUpstream(upstream string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstream)), "https://")
|
||||
}
|
||||
|
||||
func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) {
|
||||
if path == "" {
|
||||
b.WriteString(" location / {\n")
|
||||
@ -256,7 +260,17 @@ func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool)
|
||||
b.WriteString(" gzip off;\n chunked_transfer_encoding on;\n")
|
||||
}
|
||||
b.WriteString(" proxy_read_timeout 86400s;\n proxy_send_timeout 86400s;\n")
|
||||
if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://") {
|
||||
// 只在**显式勾选** insecureTls 的 https 上游上关闭证书校验。
|
||||
//
|
||||
// 审计第 2 条:这里原本是 `r.InsecureTLS || 上游是 https://`,等于所有 https 上游
|
||||
// 都不校验证书 —— 中间人可冒充任意 https 后端,用户输入与 cookie 全部可截。
|
||||
// 现在普通 https 上游走 nginx 默认(校验上游证书、走系统 CA);自签上游会 502,
|
||||
// 这是**预期行为**,要自签就勾 insecureTls。别改回去。
|
||||
//
|
||||
// 另外仍然带上「必须是 https 上游」这半个条件(而不是完全去掉):http 上游上没有
|
||||
// TLS 握手,这行是纯噪声且容易让人误以为已关掉什么安全机制;结果与旧行为一致
|
||||
// (http 上游从来不需要它),只是不再把「是 https」当成关闭校验的理由。
|
||||
if r.InsecureTLS && isHTTPSUpstream(r.Upstream) {
|
||||
b.WriteString(" proxy_ssl_verify off;\n")
|
||||
}
|
||||
if r.InjectBeian {
|
||||
@ -359,7 +373,7 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
|
||||
b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
if r.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://") {
|
||||
if r.InsecureTLS && isHTTPSUpstream(up) {
|
||||
b.WriteString(" proxy_ssl_verify off;\n")
|
||||
}
|
||||
b.WriteString(" proxy_redirect off;\n }\n")
|
||||
|
||||
208
admin-server/gateway_tls_verify_test.go
Normal file
208
admin-server/gateway_tls_verify_test.go
Normal file
@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user