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
三条规则:
- 外层必须大于内层,且留出重试余量。反了就会出现“上游还在算、调用方已放弃”,资源白烧。
- 超时要能传递,而不是每层各拍一个值。gRPC 用
grpc-timeout头自动传递 deadline,HTTP 常用X-Request-Timeout/X-Envoy-Upstream-Rq-Timeout-Ms约定;没有传递机制时,至少要保证“每层都比上一层小”。 - 重试必须有预算。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 因此要求把这种消息当错误处理:拒绝 + 关闭连接,且不复用被污染的连接。