反向代理场景下,很多后端服务会依赖转发过来的请求头获取客户端的真实访问信息,比如域名、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 重要注意事项

  1. 绝对不要让后端服务直接暴露在公网,所有外部请求必须经过网关;
  2. 明确可信代理的范围,后端只信任这些代理的请求,拒绝其他来源;
  3. 网关清洗头的时候,要把所有X-Forwarded-*类的头都删掉,不管前缀是什么;
  4. 改写X-Forwarded-Host的时候,必须用真实的请求Host,不能用写死的值;
  5. 测试的时候一定要模拟伪造头的情况,比如用Postman加自定义的X-Forwarded-For,检查后端会不会被欺骗,确保安全机制生效。