上个月把网关从 Go 1.24 升到 1.26.0,IDE 和 staticcheck 开始标出 httputil.ReverseProxy.Director 已废弃。用 go doc 看 net/http/httputil 包,能看到 Director 字段的 Deprecated 标记,官方给的迁移方向是 Go 1.20 引入的 Rewrite 钩子。这不止是换函数签名的问题:Director 拿到的 req 来自客户端原始请求,反向代理在转发给后端前会做 hop-by-hop 头清理。恶意客户端可以在请求里声明 Connection: X-Auth-User,代理清理时会把 Director 刚注入的 X-Auth-User 当成 hop-by-hop 头一起剥掉。后端拿到的请求丢了认证身份,可能被当成匿名请求处理。
Hop-by-hop 头完整列表是:
Connection
Keep-Alive
Proxy-Authenticate
Proxy-Authorization
TE
Trailer
Transfer-Encoding
Upgrade
以及 Connection 头里列出的任何头
复现步骤很简单。起一个后端打印收到的请求头,Director 里注入 X-Auth-User: svc-backend,客户端请求带 Connection: X-Auth-User:
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.Header.Set("X-Auth-User", "svc-backend")
},
}
curl -H 'Connection: X-Auth-User' http://127.0.0.1:8080/api
后端日志里 X-Auth-User 是空的。Director 废弃不是形式问题,是实际安全漏洞。
迁移到 Rewrite 时,改头对象不同。Rewrite 收到的是 *httputil.ProxyRequest,In 是原始请求,Out 是新构造的请求副本,要往 pr.Out.Header 上设置,不是 pr.In.Header:
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(svcBackendURL)
pr.Out.Header.Set("X-Auth-User", "svc-backend")
},
}
迁移中踩到的几个坑:
- Director 里手动设置的
req.URL.Scheme、req.URL.Host,在 Rewrite 里可以直接用pr.SetURL(url)代替,Host 也会跟着处理。 - 往
pr.In.Header里写 Header 不生效,In只用来读客户端请求语义。 - 旧 Director 里如果有"决定是否放行、直接写响应"的逻辑,迁到 Rewrite 后要挪到
pr.In.Context()或请求链路上处理,Rewrite 阶段拿不到http.ResponseWriter,不能直接写响应。 - 只把函数签名换成 Rewrite 但不设置转发目标,
Out就是空的,行为直接错乱。
顺带记录另一个坑:Go 1.26.6 和 1.25.13 的安全公告修了十项安全问题,其中两项是恶意的 GOPROXY 或 GOSUMDB 可以绕过校验和数据库,把模块内容偷偷替换成攻击者版本。这直接影响"有 go.sum 就安全"的直觉——如果代理或校验和服务器本身不可信,它可以在首次拉取时给出伪造的校验和,go.sum 存下的是坏 hash,后续 go mod verify 也发现不了差异。
检查命令:
go env GOPROXY GOSUMDB
go mod verify
go mod verify 只验证已下载模块和 go.sum 的一致性,防不住"首次写入就写入坏 hash"的场景。确认 GOSUMDB 指向官方 sumdb,别在不可信环境里随便用自定义 GOPROXY。
版本升级路径按原文结论直接执行:
- 1.26.0 到 1.26.6 全部升到 1.26.7+
- 1.25.x 分支需要 1.25.13+(原文未说明 1.25.14 是否存在)
- 1.26.7 修复的是 net/http 相关 bug
升级时把本机和 CI 的 Go toolchain 换成 1.26.7 即可,go.mod 里的 go 1.26 不用改,patch 版本不影响最小 go 指令。如果项目用了 toolchain 指令,可以用命令触发切换:
go mod edit -toolchain=go1.26.7
go mod verify
最后给迁移加一个回归测试,模拟客户端用 Connection 声明剥除头的攻击方式,防止日后有人把 Director 改回去:
func TestRewriteKeepsAuthHeader(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "X-Auth-User=%q", r.Header.Get("X-Auth-User"))
}))
defer backend.Close()
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(mustURL(backend.URL))
pr.Out.Header.Set("X-Auth-User", "svc-backend")
},
}
req := httptest.NewRequest("GET", "http://gateway/api", nil)
req.Header.Set("Connection", "X-Auth-User") // 模拟恶意 hop-by-hop 声明
rr := httptest.NewRecorder()
proxy.ServeHTTP(rr, req)
if got := rr.Body.String(); !strings.Contains(got, "X-Auth-User=\"svc-backend\"") {
t.Fatalf("auth header was stripped: %s", got)
}
}
func mustURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(err)
}
return u
}
废弃警告的精确输出文本以实际工具链为准,原文未提供;Director 的移除时间也是原文未提供的信息,1.26 只是正式标记 Deprecated。