8.10 反向代理与转发策略

一句话

转发策略只有四件事——发给谁、发过去长什么样、发不出去怎么办、回程怎么改回来;四件事都有默认值,而默认值是为“能跑通”设计的,不是为“不越权、不泄漏、不放大故障”设计的。

组件视角的问题——选哪款代理、TLS 在哪终结、缓冲与超时旋钮怎么给、有哪些反模式——主场在 47.1 反向代理。这里只回答“策略”:同一条请求经过代理时,哪些字段被谁按什么规则改掉,以及改错之后的故障长什么样。

一条请求在代理里被改了五次

步骤 输入 输出 典型错误
① 匹配 方法、Host、路径、Header “这条请求属于哪个路由/上游” 前缀无边界、大小写、编码未归一化
② 改写请求 原请求 新路径、新 Host、新头 路径拼接错误、Host 不透传、XFF 覆盖
③ 选择上游 路由 + 上游列表 具体实例 只按连接数、忽略在途请求
④ 兜底 失败/超时 重试、熔断、降级、错误页 无上限重试、超时预算倒挂
⑤ 改写响应 上游响应 客户端看到的响应 Location/Cookie 泄漏内部域名

🔑 这五步里,最贵的不是性能,是信任边界:凡是从客户端来的(路径、Host、XFF、Cookie)都不能直接当“事实”用;凡是从上游来的(Location、内部头、错误详情)都不能直接透给客户端。

三种“转发”:隧道、代理、重定向

方式 客户端是否感知 URL 是否变 TLS 是否重建 能否按内容路由/改写 典型场景
TCP 隧道(L4 / stream) 不感知 不变 不重建(透传或 SNI 路由) 不能 数据库代理、gRPC 透传、TLS 直通
七层反向代理 不感知 不变(除非改写) 重建(客户端一侧、上游一侧各一段) 能 HTTP API、灰度、鉴权、限流
重定向(302/307) 感知 变 由新地址决定 不适用 登录跳转、地域调度、HTTP→HTTPS

四层转发与七层的职责分工见 8.9 四层与七层负载均衡;两者的边界为什么在“看不看请求内容”,那篇文章里有完整对比。

选错的代价:该重定向却做成代理,代理就得替上游兜住跨域、Cookie 域、绝对地址回写,漏一处就出错(典型是登录后跳回内网域名);该代理却做成重定向,客户端多一次 RTT,目标地址还必须公网可达,CORS 与 Cookie 也要重配。307/308 保留方法与请求体,301/302 会被多数客户端把 POST 改成 GET——需要保方法时用 307。

匹配:前缀不是字符串前缀

最经典的事故:路由规则写“路径以 /api 开头就转给用户服务”,于是 /api-internal/debug 也被转给了用户服务。字符串前缀匹配和“路径段(segment)前缀”是两件事:

写法 /api/users /api-internal/debug /apix 结论
HasPrefix(path, "/api") 命中 命中(错) 命中(错) 事故写法
path == "/api" \|\| HasPrefix(path, "/api/") 命中 不命中 不命中 按段边界
最长前缀优先 /api/users 归 /api 归 /api-internal 无路由 正确顺序

除了边界,还有四个必须归一化的点:

点 例子 后果
.. 与 // /api/../admin、//admin 若上游会把路径归一化,就绕过了前缀规则
百分号编码 %2e%2e、双重编码 %252e 解码一次与解码两次的组件看到不同路径
反斜杠 /api\..\admin Windows 后端把 \ 当 /
尾部斜杠与大小写 /API/users/ 部分框架大小写不敏感、部分不敏感于斜杠,规则失配

nginx 的优先级值得记住(否则“我明明写了正则却不生效”):先找最长匹配前缀;若该前缀带 =(精确匹配)或 ^~(命中后不再试正则),直接用它;否则按配置顺序试 ~/~* 正则,第一个命中就用;全都不命中才回落到最长前缀。

⚠️ 路径归一化必须“只做一次,且在最外层做”。代理归一化、上游再归一化、WAF 又归一化,三者对 %2e、..、; 的处理不一致时,就同时产生了越权与请求走私两类问题。

改写:路径、Host、头

路径改写有三种常见语义,选错就是 404:

语义 规则 请求 上游收到
去前缀(strip) /api/users/1 去掉 /api /users/1
保留前缀(pass) /api/users/1 原样 /api/users/1
挂载到子路径 /users/1 拼到上游 base /backend/users/1

Host 头:七层代理默认会改写 Host(因为要向上游建新连接)。上游用虚拟主机、签名校验、多租户域名时,Host 被改错会直接 400/403。要保留就显式配置(nginx proxy_set_header Host $host;),要改写就明确改成哪个。

安全上有两个进阶坑:

  • Host 与 TLS SNI 不一致:客户端 SNI 是 a.example.com、Host 是 b.example.com,多数代理只按其中一个路由。要么强制两者一致,要么明确“以谁为准”。
  • absolute-form 请求行:GET http://evil.com/ HTTP/1.1 这种写法是给正向代理用的。如果反向代理接受它,就等于对外提供了一个开放代理(可被用来刷流量、绕过出口管控)。必须拒绝或强制改为 origin-form。

转发头:XFF 的信任边界

X-Forwarded-For 是一串“谁转发过来的”记录。它是客户端可以随便写的,所以正确姿势是“追加”,而判断“哪个是真实客户端 IP”要靠信任跳数,不是靠“取第一个”:

做法 客户端发 XFF: 8.8.8.8 时,上游看到 应用取到的“客户端 IP”
覆盖 8.8.8.8(代理自己的判断被抹掉) 永远错
原样透传 8.8.8.8 8.8.8.8(伪造成功)
只在不存时设置 8.8.8.8 8.8.8.8(伪造成功)
追加(nginx $proxy_add_x_forwarded_for) 8.8.8.8, 10.0.0.5 取右起第 1 跳 → 10.0.0.5(正确)

多级代理时,右起第 N 个才是真实客户端(N = 可信代理层数)。RFC 7239 的 Forwarded: for=192.0.2.1;proto=https;host=example.com 是标准写法,但生态兼容性不如 XFF;两者都配、只信一个,是常见做法。

hop-by-hop 头与 Connection 令牌

Connection 头不只是“keep-alive”,它还可以点名一些头,表示“这些头只对当前这一跳有效”:

Connection: X-Debug, close
X-Debug: 1

按 RFC 9110,收到这种请求的代理必须先删掉 Connection 里点名的头(这里是 X-Debug),再转发。标准 hop-by-hop 列表还有 Keep-Alive、TE、Trailer、Transfer-Encoding、Upgrade、Proxy-Authenticate、Proxy-Authorization。

不删会怎样:① 客户端能把本该由代理设定的头(X-Real-IP、内部鉴权头)直接写进请求,代理原样送到上游;② Connection 令牌被透传,上游会按客户端点名的清单去删头,等于把连接语义交给客户端决定;③ 上游返回的 Transfer-Encoding/Connection 被透传给客户端,会造成连接语义错乱。

💡 例外是 Upgrade:WebSocket 升级必须把 Connection: Upgrade 与 Upgrade: websocket 透传给上游,并关闭缓冲、放宽 idle 超时。正常请求要删,升级请求要留——这就是为什么“统一剥掉所有 hop-by-hop 头”的中间件会把 WebSocket 弄坏。

回程改写:Location、Cookie、错误页

上游只知道自己的内部地址,所以它会理直气壮地返回:

HTTP/1.1 302 Found
Location: http://users.internal:9101/login?next=/

客户端拿到这个地址,要么打不开(内网域名),要么打到了不该暴露的主机。七层代理必须做回程改写:把 Location 里的内部 scheme+host 换成对外的(或直接改成相对路径 /login?next=/)。nginx 用 proxy_redirect,Envoy 用 RouteAction.host_rewrite/ResponseMapper,Go 用 ReverseProxy.ModifyResponse。

同类问题还有:

响应头 问题 处理
Location / Refresh 内部域名泄漏、跳转失败 重写为对外地址或相对路径
Set-Cookie 的 Domain 内部域名导致浏览器拒收 改写成对外域名,或去掉 Domain
Set-Cookie 的 Path 与对外路径前缀不一致 与 strip/pass 语义对齐
上游错误详情(堆栈、SQL) 信息泄漏 统一错误页,详情只进日志
Server/X-Powered-By 暴露版本 删除或改写

多级代理链路上,X-Forwarded-Host、CORS 预检与重定向会互相影响(一个代理回写了 URL,另一个又按原 Host 判跨域),这类组合问题见 8.12 代理链、跨域与重定向。

发不出去怎么办:超时、重试、熔断的预算传递

超时的三个旋钮(connect / read / idle)与重试的四个安全条件,主场见 47.1 反向代理;这里只讲预算怎么在链上传——这是多级代理最容易配错的地方。

客户端超时 3s  >  入口代理 2s  >  内层代理 1.5s  >  上游处理 1s

三条规则:

  1. 外层必须大于内层,且留出重试余量。反了就会出现“上游还在算、调用方已放弃”,资源白烧。
  2. 超时要能传递,而不是每层各拍一个值。gRPC 用 grpc-timeout 头自动传递 deadline,HTTP 常用 X-Request-Timeout/X-Envoy-Upstream-Rq-Timeout-Ms 约定;没有传递机制时,至少要保证“每层都比上一层小”。
  3. 重试必须有预算。3 次重试意味着故障时最多 4 倍流量(预算与退避的完整讨论见 35.5 超时、重试与退避);正确做法是“重试预算”(如成功请求的 10% 以内可重试)+ 只对幂等请求重试 + 指数退避 + 抖动。
失败类型 能否重试 原因
连接被拒绝 / 建连超时 可以(幂等时) 请求没发出去
上游 503 可以(有上限) 通常是过载,重试要配预算
响应超时 谨慎 请求可能已执行
非幂等 POST 不要 重复下单/扣款
4xx 不要 重试不会变好

熔断与异常点剔除(outlier detection) 解决的是“健康检查还没发现,但实例已经半死”:连续 N 次 5xx/超时就临时摘除,隔一段时间放一个探针请求试探。它比健康检查快,但需要按实例统计,且要防止“把整片实例同时摘光”——所以要有最小健康实例数保护。

缓冲与流式:SSE 为什么会“攒着不发”

代理默认会缓冲响应:收完(或者收满一个缓冲块)再往客户端发。对普通 JSON 这是优点(隔离慢客户端),对 SSE/流式 gRPC 就是灾难——事件全部攒在代理里,直到连接关闭才一次性吐出。

原因 现象 处理
响应缓冲 事件延迟数秒到数十秒,直到连接关闭才一次性出现 proxy_buffering off 或 X-Accel-Buffering: no
gzip 压缩 压缩器要攒够一块才输出 SSE 路径关 gzip,或强制 flush
上游是 HTTP/1.0 没有 chunked,只能靠关闭连接表示结束 proxy_http_version 1.1
请求侧 Connection 头被透传 上游按 close 处理,逐请求建连,h2 下语义更乱 清空上游 Connection 头(proxy_set_header Connection "";)
客户端/代理 idle 超时太短 长时间无事件被断开 心跳(:\n\n 注释行)间隔 < idle 超时(长连接的空闲回收见 8.3 连接复用与 TIME_WAIT)

请求方向同理:大文件上传时如果代理会先把整个请求体收完再转发(请求缓冲),上传会变成“先传完再开始处理”,首字节时间翻倍。要么关掉请求缓冲,要么把上限配置得足够大并给磁盘缓冲留空间。

请求走私:两个解析器不一致时

HTTP/1.1 的文本分帧为什么给走私留了空间,见 8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC)。

代理与上游都要解析 Content-Length / Transfer-Encoding 才能切分请求体。两者对同一个请求得出不同的边界时,攻击者就能把“第二个请求”藏进第一个请求的体内,让上游把它当成一个新请求——这就是请求走私(CL.TE / TE.CL / TE.TE)。

变体 利用点
CL.TE 代理信 Content-Length,上游信 Transfer-Encoding: chunked
TE.CL 反过来
TE.TE 两端都支持 TE,但对畸形 TE(如 chunked, chunked)处理不同

防御只有三条,且都必须做:① 同时出现 CL 与 TE 时直接拒绝(RFC 9112 建议按错误处理);② 全链路统一用同一个解析器/同一套规则(包括 WAF、网关、sidecar);③ 一旦发现异常请求,关闭连接,不复用——否则被污染的连接会把“藏起来的请求”带给下一个用户。

⚠️ 走私的危害不止“绕过 WAF”:它能让攻击者的请求以别人的身份(下一个复用该连接的用户)被执行,也能污染缓存。只在入口做 WAF 而不统一解析规则,等于没防。

实测:同一组请求,反面与正面代理的差别

下面这段程序起两个上游(users 与 admin,路径空间互不重叠)、两个代理:反面只做字符串前缀匹配、头原样复制、Location 不回写;正面按段边界 + 最长前缀路由、用 ReverseProxy.Rewrite 重建转发头、剥 hop-by-hop、回写 Location。然后打同一组探针,对比两侧结果。

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strings"
	"time"
)

const (
	usersAddr = "127.0.0.1:9101"
	adminAddr = "127.0.0.1:9102"
)

// ---------- 两个上游服务:路径空间互相独立 ----------

func usersUpstream() *http.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "svc=users path=%s xff=%q xdebug=%q",
			r.URL.Path, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Debug"))
	})
	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
		// 上游只知道自己的内部域名,绝对地址重定向是最常见的写法
		w.Header().Set("Location", "http://users.internal:9101/login?next=/")
		w.WriteHeader(http.StatusFound)
	})
	return &http.Server{Addr: usersAddr, Handler: mux}
}

func adminUpstream() *http.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("/debug", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "svc=admin path=%s", r.URL.Path)
	})
	return &http.Server{Addr: adminAddr, Handler: mux}
}

// ---------- 反面示范:前缀匹配无边界、头原样透传、Location 不回写 ----------

func naiveProxy() http.Handler {
	client := &http.Client{
		Timeout:       3 * time.Second,
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.HasPrefix(r.URL.Path, "/api") { // 没有 segment 边界
			http.NotFound(w, r)
			return
		}
		rest := strings.TrimPrefix(r.URL.Path, "/api")
		if !strings.HasPrefix(rest, "/") {
			rest = "/" + rest
		}
		req, err := http.NewRequest(r.Method, "http://"+usersAddr+rest, r.Body)
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		for k, vv := range r.Header { // 原样复制:hop-by-hop 头也带过去
			for _, v := range vv {
				req.Header.Add(k, v)
			}
		}
		resp, err := client.Do(req)
		if err != nil {
			http.Error(w, err.Error(), 502)
			return
		}
		defer resp.Body.Close()
		for k, vv := range resp.Header { // Location 原样透传
			for _, v := range vv {
				w.Header().Add(k, v)
			}
		}
		w.WriteHeader(resp.StatusCode)
		io.Copy(w, resp.Body)
	})
}

// ---------- 正面示范:段边界 + 最长前缀 + Rewrite 头治理 + Location 回写 ----------

type route struct {
	prefix string
	proxy  http.Handler
}

func fixedProxy() http.Handler {
	routes := []route{
		{"/api-internal", newProxy(adminAddr, "")}, // 更长前缀先匹配
		{"/api", newProxy(usersAddr, "http://users.internal:9101")},
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		for _, rt := range routes {
			// 边界判断:/api 命中 /api、/api/x,但绝不命中 /api-internal/x
			if r.URL.Path == rt.prefix || strings.HasPrefix(r.URL.Path, rt.prefix+"/") {
				r2 := new(http.Request)
				*r2 = *r
				r2.URL = new(url.URL)
				*r2.URL = *r.URL
				r2.URL.Path = strings.TrimPrefix(r.URL.Path, rt.prefix)
				if r2.URL.Path == "" {
					r2.URL.Path = "/"
				}
				rt.proxy.ServeHTTP(w, r2)
				return
			}
		}
		http.NotFound(w, r)
	})
}

func newProxy(addr, rewriteFrom string) http.Handler {
	p := &httputil.ReverseProxy{
		Transport: &http.Transport{
			MaxIdleConnsPerHost: 64,
			DialContext:         (&net.Dialer{Timeout: 1 * time.Second}).DialContext,
		},
		// Rewrite 会先剥掉客户端自带的 X-Forwarded-*,再按下面逻辑重建
		Rewrite: func(pr *httputil.ProxyRequest) {
			pr.Out.URL.Scheme, pr.Out.URL.Host = "http", addr
			pr.Out.Host = addr
			pr.SetXForwarded() // XFF 先重置为「真实客户端 IP」
			// 再把客户端自带的链拼到前面:等价 nginx 的 $proxy_add_x_forwarded_for
			if prior := pr.In.Header.Get("X-Forwarded-For"); prior != "" {
				pr.Out.Header.Set("X-Forwarded-For", prior+", "+pr.Out.Header.Get("X-Forwarded-For"))
			}
		},
		ModifyResponse: func(resp *http.Response) error {
			if loc := resp.Header.Get("Location"); loc != "" {
				if u, err := url.Parse(loc); err == nil && rewriteFrom != "" &&
					u.Scheme+"://"+u.Host == rewriteFrom {
					resp.Header.Set("Location", u.RequestURI()) // 内部主机名不外泄
				}
			}
			return nil
		},
		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
			fmt.Printf("[fixed] 上游失败: %v\n", err)
			http.Error(w, "upstream unavailable", http.StatusBadGateway)
		},
	}
	return p
}

// ---------- 探测 ----------

func main() {
	for _, s := range []*http.Server{usersUpstream(), adminUpstream()} {
		go s.ListenAndServe()
	}
	go http.ListenAndServe("127.0.0.1:9100", naiveProxy())
	go http.ListenAndServe("127.0.0.1:9110", fixedProxy())
	time.Sleep(300 * time.Millisecond)

	client := &http.Client{
		Timeout:       3 * time.Second,
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}

	type probe struct {
		name string
		path string
		hdr  map[string]string
	}
	probes := []probe{
		{"普通请求", "/api/users/1", nil},
		{"前缀越界 /api-internal/debug", "/api-internal/debug", nil},
		{"客户端伪造 XFF", "/api/users/1", map[string]string{"X-Forwarded-For": "8.8.8.8"}},
		{"上游绝对地址重定向", "/api/login", nil},
		{"Connection 令牌带内部头", "/api/users/1", map[string]string{"Connection": "X-Debug", "X-Debug": "1"}},
	}

	do := func(port int, p probe) (string, string) {
		req, _ := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d%s", port, p.path), nil)
		for k, v := range p.hdr {
			req.Header.Set(k, v)
		}
		resp, err := client.Do(req)
		if err != nil {
			return "ERR", err.Error()
		}
		defer resp.Body.Close()
		body, _ := io.ReadAll(resp.Body)
		if loc := resp.Header.Get("Location"); loc != "" {
			return fmt.Sprint(resp.StatusCode), "Location=" + loc
		}
		return fmt.Sprint(resp.StatusCode), strings.TrimSpace(string(body))
	}

	for _, p := range probes {
		ns, nb := do(9100, p)
		fs, fb := do(9110, p)
		fmt.Printf("\n【%s】\n  反面 %s %s\n  正面 %s %s\n", p.name, ns, nb, fs, fb)
	}
}

本机实测(macOS,Apple M1,Go 1.27;Rewrite 需要 Go 1.20+):

【普通请求】
  反面 200 svc=users path=/users/1 xff="" xdebug=""
  正面 200 svc=users path=/users/1 xff="127.0.0.1" xdebug=""

【前缀越界 /api-internal/debug】
  反面 404 404 page not found
  正面 200 svc=admin path=/debug

【客户端伪造 XFF】
  反面 200 svc=users path=/users/1 xff="8.8.8.8" xdebug=""
  正面 200 svc=users path=/users/1 xff="8.8.8.8, 127.0.0.1" xdebug=""

【上游绝对地址重定向】
  反面 302 Location=http://users.internal:9101/login?next=/
  正面 302 Location=/login?next=/

【Connection 令牌带内部头】
  反面 200 svc=users path=/users/1 xff="" xdebug="1"
  正面 200 svc=users path=/users/1 xff="127.0.0.1" xdebug=""

逐行读:

探针 反面暴露的问题 正面的处理
普通请求 上游拿不到客户端 IP SetXForwarded 补上 XFF
/api-internal/debug 被 /api 规则接走,打到用户服务(404),路由规则互相吞并 段边界 + 最长前缀,正确落到 admin
伪造 XFF 上游只看到 8.8.8.8,应用“取第一个”就采信了伪造值 追加成 8.8.8.8, 127.0.0.1;应用取右起第 1 跳才是真实 IP
绝对地址重定向 浏览器拿到 users.internal,打不开 改写成相对路径 /login?next=/
Connection 令牌 X-Debug 被透传——没有头治理,客户端写什么上游就收什么 按 hop-by-hop 语义剥掉令牌点名的头,上游看不到 X-Debug

正面之所以能剥掉 X-Debug,并不是那段代码显式删了它,而是 httputil.ReverseProxy 的内置行为:它按 RFC 9110 删除 hop-by-hop 头以及 Connection 点名的头。换 nginx/Envoy 时这些是默认行为,换成自研代理就必须自己写——“框架帮我做了”和“我知道为什么要做”是两件事。

💡 第 2 行的 404 不是“没找到”,而是找错了服务:用户服务收到 /-internal/debug。真实系统里更危险的是反向情形——/internal 恰好也是用户服务的一个合法路径,越权就发生了。

参数表:按场景给

场景 关键配置
普通 HTTP API 段边界路由 + XFF 追加 + Location 回写 + connect 1–3s / read 按 P99×2
大文件下载/上传 关响应缓冲或限速、Range 透传、请求缓冲上限调大、read 超时放宽
SSE / 流式 gRPC 关缓冲、关 gzip、proxy_http_version 1.1、心跳 < idle 超时
WebSocket 透传 Upgrade/Connection、关缓冲、idle 超时按心跳定
灰度发布 按 header/cookie/权重路由,保留回切开关,粘滞要能关
多租户入口 按 Host 路由 + 每租户限流(35.7 限流)
上游是内部 HTTPS 决定“终止再重建”(可做 mTLS/审计)还是“TLS 直通”(不可审计)
需要对外隐藏拓扑 删除 Server/X-Powered-By、统一错误页、Location 全部回写

代理链上的头处理与跨域/重定向语义见 8.12 代理链、跨域与重定向;代理按域名分流时依赖解析结果,见 8.4 DNS 解析链路与缓存。

反模式

反模式 后果
路径规则用字符串前缀,不做段边界 路由互相吞并,请求打到错误服务甚至越权
只在外层做归一化,内层再解析一次 越权与请求走私的温床
XFF 覆盖或原样透传 限流/风控/AI 风控全部可被伪造 IP 绕过
用 X-Forwarded-For 的第一个值当客户端 IP 攻击者可控字段被当成事实
透传 hop-by-hop 头 连接语义交给客户端决定;代理没治理的头会原样到上游
Location/Cookie 不回写 内部域名泄漏,登录跳转打不开
全局开响应缓冲 大文件打满内存,SSE 事件延迟到连接关闭
SSE 路径开着 gzip + 缓冲 事件“攒着不发”,误判为上游慢
代理层无上限重试 故障时流量 ×4,雪崩加速
同时接受 CL 与 TE 请求走私,绕过 WAF、冒用他人身份
允许 absolute-form 请求行 变成开放代理,被用来刷流量/绕过出口管控
超时逐层拍脑袋、外层小于内层 上游白算、连接被占满,P99 与错误率一起涨

自检

Q1 路由规则是“/api 开头转发到用户服务”,上线后发现 /api-internal/debug 也能被外部访问到。是匹配写错了,还是鉴权写错了?怎么彻底修?

▸ 答案:匹配写错了,属于“字符串前缀”冒充“路径段前缀”。/api-internal/debug 以 /api 开头,于是被用户服务的规则接走并去掉前缀变成 /-internal/debug;如果用户服务恰好有匹配的路径,就直接越权。彻底修:① 匹配按段边界(path == p || strings.HasPrefix(path, p+"/"))并最长前缀优先;② 对归一化(..、%2e、//、\)只在最外层做一次;③ 内部路径(/internal、/debug)不要与对外路由共用同一命名空间。

Q2 应用从 X-Forwarded-For 里取第一个值当客户端 IP,用来做限流。攻击者怎么绕过?正确的取值与配置是什么?

▸ 答案:攻击者直接在请求里带 X-Forwarded-For: 1.1.1.1(每次换一个),代理如果“原样透传”或“只在不存在时设置”,上游看到的就是伪造值,限流按伪造 IP 计数,等于失效。正确做法:代理追加(8.8.8.8, 真实IP),应用按可信代理跳数从右往左取(单层代理取右起第 1 个),并且只信任来自已知代理网段的 XFF;同时用 Forwarded 标准头或 mTLS 身份做交叉校验。核心原则:XFF 是链,不是值;左边是客户端可控的,右边是可信代理写的。

Q3 上游返回 302 Location: http://users.internal:9101/login,用户浏览器打不开。问题出在哪一层?除了改代理,还有什么修法,各有什么代价?

▸ 答案:问题在回程改写缺失——上游只会写自己知道的内部地址,代理需要把它映射成对外地址(nginx proxy_redirect、Go ModifyResponse、Envoy host_rewrite)。替代修法:① 让上游配置 PUBLIC_BASE_URL 直接生成对外地址——需要每个服务都改,且多环境配置容易漏;② 全部用相对路径重定向——最简单,但跨域名跳转时做不到;③ 在 DNS/入口层把内部域名也解析到同一入口——会把内部拓扑暴露给公网。推荐“代理回写 + 上游只用相对路径”组合,二者互为兜底。

Q4 SSE 经过代理后,事件延迟十几秒、最后一次性全出现。列出三个可能原因,以及各自的验证方法。

▸ 答案:① 响应缓冲(proxy_buffering on 或应用侧 bufio 未 flush)——验证:直连上游看是否实时,经过代理看是否攒包;② gzip 压缩(压缩器要攒块)——验证:请求头带 Accept-Encoding: identity 再试;③ 上游是 HTTP/1.0 或无 chunked,代理只能等连接结束——验证:看响应的 Transfer-Encoding/HTTP/1.0,以及 proxy_http_version 配置。修法:关缓冲(X-Accel-Buffering: no)、SSE 路径关 gzip、上游用 1.1 并逐条 Flush,再加心跳行防止 idle 超时。

Q5 为什么“同时收到 Content-Length 和 Transfer-Encoding 就拒绝”比“选一个用”更安全?

▸ 答案:因为两端可能选得不一样。代理选 CL、上游选 TE(或反之),同一条字节流就被切成不同边界,攻击者把第二个请求藏在第一个的 body 里,上游会把它当成新请求执行——这就是请求走私,可绕过 WAF、污染缓存、冒用下一个复用连接的用户身份。“选一个用”只有在全链路解析器与规则完全一致时才安全,而现实中 WAF、网关、sidecar、框架各不相同。RFC 9112 因此要求把这种消息当错误处理:拒绝 + 关闭连接,且不复用被污染的连接。

关联