反向代理场景下,很多后端服务会依赖转发过来的请求头获取客户端的真实访问信息,比如域名、IP等,但如果这些头被恶意篡改,就会带来数据错误、权限绕过甚至安全泄露的问题。最近不少开发者在使用Hertz框架搭建反向代理时,遇到了改写X-Forwarded-Host和请求路径的需求,同时也面临如何防范转发头伪造的疑问,今天就把这个问题的来龙去脉和解决方案理清楚,让不同基础的开发者都能看懂。
一、什么是反向代理里的“信任头”问题?
1.1 反向代理的实际应用场景
很多做微服务或者前后端分离架构的开发者,都接触过反向代理。你可以把反向代理理解成公司的前台:所有外部用户的请求都先到前台,前台帮你把请求转发到内部的后端服务,后端服务永远不直接对外暴露,这样既能隐藏后端的细节,又能统一处理请求,比如做负载均衡、路由转发。在这个过程中,为了让后端服务知道“这个请求是从哪个域名过来的”“真实客户端的IP是什么”,反向代理会在转发请求的时候,添加一些特殊的头,比如X-Forwarded-Host、X-Forwarded-For,这些就叫“信任头”,因为它们是代理加的,正常来说是可信的。
1.2 为什么转发头会被伪造?
但信任头的问题在于:如果有人绕过反向代理,直接给后端服务发请求,并且带上自己造的X-Forwarded-Host或者X-Forwarded-For,后端服务就会以为这个假的头是真的。更麻烦的是,如果中间有多个代理,恶意用户还可以把自己的头插在中间,进一步混淆真实信息。比如,后端用X-Forwarded-For里的IP做登录限制,恶意用户改了这个头,就能绕过登录频率限制,这就是典型的转发头伪造带来的安全隐患。
二、用Hertz改写转发头和路径的正确姿势
Hertz是字节跳动开源的高性能Go语言框架,很多开发者用它做反向代理。改写X-Forwarded-Host和请求路径,是反向代理的常见需求,比如把所有/api开头的请求去掉前缀,只把核心路径转给后端,后端不用处理多余的前缀;同时改写X-Forwarded-Host,确保后端拿到的是真实的访问域名,而不是代理的域名。
2.1 Hertz改写请求头和路径的示例
这个示例用Hertz做反向代理,监听8080端口,把请求转发到后端127.0.0.1:9090,同时改写X-Forwarded-Host和请求路径,代码如下:
package main
import (
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/proxy/client"
)
func main() {
// 初始化Hertz反向代理服务,监听本地8080端口
h := server.Default(server.WithHostPorts(":8080"))
// 匹配所有路径的请求,统一处理转发
h.Any("/*anyPath", func(ctx *app.RequestContext) {
// 第一步:改写X-Forwarded-Host头,用当前请求的真实Host,确保后端拿到用户实际访问的域名
ctx.Request.Header.Set("X-Forwarded-Host", string(ctx.Request.Host()))
// 第二步:改写请求路径,假设后端服务需要去掉/api前缀,比如把/api/user改成/user再转发
currentPath := string(ctx.Request.URI().Path())
// 判断路径是否以/api开头,是则去掉前缀
if len(currentPath) > 4 && currentPath[:4] == "/api" {
newPath := currentPath[4:]
ctx.Request.SetURI(newPath) // 设置新的请求路径
}
// 第三步:把请求转发到后端服务,设置5秒超时,避免长时间等待
_, err := client.GetClient().ProxyHTTP(ctx, "http://127.0.0.1:9090")
if err != nil {
ctx.SetStatusCode(500)
ctx.Response.SetBody([]byte("代理转发失败:" + err.Error()))
return
}
})
// 启动反向代理服务
h.Spin()
}
2.2 这里要注意的小细节
刚才的代码里,改写路径和Host的时候,一定要先拿到请求的原始信息,不能随便填死的数值。另外,X-Forwarded-Host的作用非常关键,后端服务如果用这个头做域名相关的逻辑,比如多域名路由,就必须确保这个头是正确的,所以代理改写的时候,一定要用真实的Host,不能出错。
三、后端服务怎么防范转发头伪造?
后端服务直接接收代理转发的请求,所以必须有一套机制,确保自己拿到的X-Forwarded-Host和客户端IP是可信的,不能随便相信任何请求里的这些头。
3.1 为什么不能直接用转发头?
之前说了,外部用户如果直接发请求到后端,带上伪造的X-Forwarded-For,后端会被骗;如果中间有多个代理,也可能有恶意代理篡改头。所以后端的核心原则是:只相信来自可信代理的转发头,对于其他来源的请求,直接拒绝。
3.2 后端服务获取真实IP的示例
后端服务同样用Hertz实现,只信任来自本地反向代理(127.0.0.1)的请求,只使用这个代理转发的头,代码如下:
package main
import (
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"net"
"strings"
)
func main() {
// 初始化后端Hertz服务,监听9090端口
h := server.Default(server.WithHostPorts(":9090"))
// 处理所有请求,获取真实客户端信息
h.GET("/*anyPath", func(ctx *app.RequestContext) {
// 1. 获取当前连接的远程地址,也就是请求来源的IP(这里就是反向代理的IP,因为我们只让代理访问后端)
remoteAddr := string(ctx.RemoteAddr())
proxyIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
ctx.SetStatusCode(400)
ctx.Response.SetBody([]byte("无效的请求来源"))
return
}
// 2. 只信任本地反向代理(127.0.0.1)的请求,其他来源直接拒绝
if proxyIP != "127.0.0.1" {
ctx.SetStatusCode(403)
ctx.Response.SetBody([]byte("禁止访问:只允许内部代理的请求"))
return
}
// 3. 从X-Forwarded-For中提取真实客户端IP,注意:只有来自可信代理的这个头才可信
forwardedFor := ctx.Request.Header.Get("X-Forwarded-For")
var clientIP string
if forwardedFor != "" {
// X-Forwarded-For是逗号分隔的IP,第一个是真实客户端,后面是中间代理
ips := strings.Split(forwardedFor, ",")
clientIP = strings.TrimSpace(ips[0])
} else {
// 正常来说,代理已经添加了这个头,不会走到这里,这里只是容错
clientIP = proxyIP
}
// 4. 获取可信的X-Forwarded-Host,确保是代理添加的真实域名
forwardedHost := ctx.Request.Header.Get("X-Forwarded-Host")
if forwardedHost == "" {
forwardedHost = string(ctx.Request.Host())
}
// 这里只是演示,实际生产中不能直接返回这些信息,要做安全校验
result := "真实客户端IP:" + clientIP + ",访问域名:" + forwardedHost + ",请求路径:" + string(ctx.Request.URI().Path())
ctx.SetStatusCode(200)
ctx.Response.SetBody([]byte(result))
})
// 启动后端服务
h.Spin()
}
四、网关层统一清洗信任头的正确姿势
刚才的例子里,后端只信任本地代理,但如果你的系统里有多个代理,或者需要对外暴露多个域名,就需要引入网关层,网关是唯一的对外入口,所有请求都必须经过网关,这样网关可以统一清洗所有旧的转发头,只添加自己生成的可信头,从根源上杜绝伪造。
4.1 网关清洗信任头的核心逻辑
网关清洗头的步骤是:① 删除所有客户端可能带的X-Forwarded-*头,不管是什么来源的,先清干净;② 添加自己生成的可信头,这些头只有网关能改,其他服务都不能篡改;③ 转发请求到后端。这样后端不管收到什么请求,都只需要信任网关添加的头,安全就有保障了。
4.2 Hertz网关清洗信任头的示例
这个示例是用Hertz做网关,监听80端口,所有外部请求都经过这里,清洗头之后转发到后端,代码如下:
package main
import (
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/proxy/client"
"net"
"strings"
)
func main() {
// 网关服务,监听80端口,是唯一的对外暴露入口
gateway := server.Default(server.WithHostPorts(":80"))
// 处理所有外部请求,清洗头后转发
gateway.Any("/*anyPath", func(ctx *app.RequestContext) {
// 第一步:清洗所有旧的X-Forwarded-*头,彻底清除用户可能伪造的头
for key := range ctx.Request.Header {
keyStr := string(key)
if strings.HasPrefix(keyStr, "X-Forwarded-") {
ctx.Request.Header.Del(keyStr)
}
}
// 第二步:添加网关生成的可信转发头,这些头只有网关能控制,不会被伪造
// 获取真实客户端的IP(远程地址)
clientAddr := string(ctx.RemoteAddr())
clientIP, _, err := net.SplitHostPort(clientAddr)
if err == nil {
// 添加X-Forwarded-For,只放真实客户端IP,后面不跟其他代理(简化示例)
ctx.Request.Header.Add("X-Forwarded-For", clientIP)
// 添加X-Real-IP,备用的真实IP头
ctx.Request.Header.Set("X-Real-IP", clientIP)
}
// 添加X-Forwarded-Host,是当前请求的真实访问域名
ctx.Request.Header.Set("X-Forwarded-Host", string(ctx.Request.Host()))
// 第三步:转发请求到后端服务,这里简化为固定地址,实际可以根据路径做动态路由
backendAddr := "http://127.0.0.1:9090"
_, err = client.GetClient().ProxyHTTP(ctx, backendAddr)
if err != nil {
ctx.SetStatusCode(500)
ctx.Response.SetBody([]byte("网关转发失败:" + err.Error()))
return
}
})
// 启动网关服务
gateway.Spin()
}
五、总结&注意事项
5.1 核心总结
反向代理场景下,Hertz改写转发头和路径的需求很常见,但必须结合安全机制:一是后端只信任可信代理的转发头;二是网关层必须统一清洗旧的信任头,只添加自己生成的可信头,这样才能避免伪造带来的安全问题。
5.2 重要注意事项
- 绝对不要让后端服务直接暴露在公网,所有外部请求必须经过网关;
- 明确可信代理的范围,后端只信任这些代理的请求,拒绝其他来源;
- 网关清洗头的时候,要把所有X-Forwarded-*类的头都删掉,不管前缀是什么;
- 改写X-Forwarded-Host的时候,必须用真实的请求Host,不能用写死的值;
- 测试的时候一定要模拟伪造头的情况,比如用Postman加自定义的X-Forwarded-For,检查后端会不会被欺骗,确保安全机制生效。
评论
围绕“反向代理场景下Hertz改写X-Forwarded-Host与请求路径,后端服务获取原始协议和客户端IP时需防范转发头伪造带来的安全隐患,因此要在网关层统一清洗信任头”参与讨论