Repository: fatedier/frp (language: go)

Issue:
HTTP代理设置BasicAuth鉴权后，无法通过CORS验证的问题
### Bug Description

浏览器在发起跨域请求时，会先发送一个preflight预检请求。该请求是浏览器自动请求的，无法设置Authorization头，导致frp鉴权失败。失败后，请求并不送到我的后端服务器，而是由frp直接返回一个401响应。而这个响应中，并未包含Access-Control-Allow-Origin头，导致后续的请求认为跨域错误，导致无法请求成功。

![image](https://github.com/fatedier/frp/assets/22488813/fc3d20b5-2254-4324-95d8-1679d4bc919e)


### frpc Version

0.49.0

### frps Version

0.34.0

### System Architecture

linux/arm/v7

### Configurations

frpc http -s xxxxxxxxxxx -l 5000 -d xxxxxxxxxxxx --http_user xxxxxx --http_pwd xxxxxxxx --bandwidth_limit 200KB --locations /$(hostname) -n $(hostname)

### Logs

_No response_

### Steps to reproduce

1. 
2. 
3. 
...


### Affected area

- [ ] Docs
- [ ] Installation
- [ ] Performance and Scalability
- [ ] Security
- [ ] User Experience
- [ ] Test and Release
- [ ] Developer Infrastructure
- [X] Client Plugin
- [ ] Server Plugin
- [ ] Extensions
- [ ] Others


Discussion / hints:
不太了解相关的背景，你期望的解决方案是什么？以及是否有其他的被广泛使用的代理提供了相应的可参考的解决方案，比如 nginx?
> 

我也遇到了同样的问题，正常穿透并通过nginx代理是没问题的，但是加了http_user/http_pwd就不行了
> 不太了解相关的背景，你期望的解决方案是什么？以及是否有其他的被广泛使用的代理提供了相应的可参考的解决方案，比如 nginx?

期望可以设置Frp在遇到HttpAuth验证失败时，可以自定义401错误响应的Header，增加跨域相关的头。目前我临时的做法是，取消frp的httpAuth，而是在我自己的后端服务中添加auth验证，如果校验错误，后端服务自己在401响应中添加对应的头。
nginx应该可以通过下列方式，为401响应添加头：
[nginx add headers when returning 400 codes](https://stackoverflow.com/a/20416736)

> For nginx >= 1.7.5
  Append "always" to the header definition:
  `add_header 'Access-Control-Allow-Origin' '*' always;`
@rty813 如果默认就附加上 `Access-Control-Allow-Origin` header 会有安全风险吗？是否是一个标准的做法？
> @rty813 如果默认就附加上 `Access-Control-Allow-Origin` header 会有安全风险吗？是否是一个标准的做法？

这个会有一定的安全风险，对于我的使用场景下，是可以这样的，但是作为一个通用的方法应该允许用户自行设定。
可以考虑支持添加返回特定 header 的功能来解决这个问题。但是这方面的能力还是有限的，复杂的需求后面还是需要依赖 nginx 之类的代理解决。
嗯嗯，如果能支持返回特定header就好了，感谢
其实是http请求处理顺序的问题，可以在所有请求前（不校验权限）处理预检请求：以gin框架为例

```go
router.Use(option())
func option() gin.HandlerFunc {
	return func(c *gin.Context) {
		if c.Request.Method == "OPTIONS" {
			setCors(c.Writer) // 这里配置跨域header
			c.JSON(http.StatusOK, "Options Request!")
			c.Abort()
			return
		}

		c.Next()
	}
}
```

Fix patch:
diff --git a/README.md b/README.md
index aedac1f8fe2..9122de1e749 100644
--- a/README.md
+++ b/README.md
@@ -983,7 +983,7 @@ The HTTP request will have the `Host` header rewritten to `Host: dev.example.com
 
 ### Setting other HTTP Headers
 
-Similar to `Host`, You can override other HTTP request headers with proxy type `http`.
+Similar to `Host`, You can override other HTTP request and response headers with proxy type `http`.
 
 ```toml
 # frpc.toml
@@ -995,9 +995,10 @@ localPort = 80
 customDomains = ["test.example.com"]
 hostHeaderRewrite = "dev.example.com"
 requestHeaders.set.x-from-where = "frp"
+responseHeaders.set.foo = "bar"
 ```
 
-In this example, it will set header `x-from-where: frp` in the HTTP request.
+In this example, it will set header `x-from-where: frp` in the HTTP request and `foo: bar` in the HTTP response.
 
 ### Get Real IP
 
diff --git a/Release.md b/Release.md
index 12dc548837c..9f3577bbab5 100644
--- a/Release.md
+++ b/Release.md
@@ -7,6 +7,7 @@ When connecting to frps versions older than v0.39.0 might encounter compatibilit
 ### Features
 
 * Show tcpmux proxies on the frps dashboard.
+* `http` proxy can modify the response header. For example, `responseHeaders.set.foo = "bar"` will add a new header `foo: bar` to the response.
 
 ### Fixes
 
diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml
index 0528ddeaf37..3d4d0347f43 100644
--- a/conf/frpc_full_example.toml
+++ b/conf/frpc_full_example.toml
@@ -209,6 +209,7 @@ locations = ["/", "/pic"]
 # routeByHTTPUser = abc
 hostHeaderRewrite = "example.com"
 requestHeaders.set.x-from-where = "frp"
+responseHeaders.set.foo = "bar"
 healthCheck.type = "http"
 # frpc will send a GET http request '/status' to local http service
 # http service is alive when it return 2xx http response code
diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go
index 1949cfd34d1..8530e2162f7 100644
--- a/pkg/config/v1/proxy.go
+++ b/pkg/config/v1/proxy.go
@@ -291,6 +291,7 @@ type HTTPProxyConfig struct {
 	HTTPPassword      string           `json:"httpPassword,omitempty"`
 	HostHeaderRewrite string           `json:"hostHeaderRewrite,omitempty"`
 	RequestHeaders    HeaderOperations `json:"requestHeaders,omitempty"`
+	ResponseHeaders   HeaderOperations `json:"responseHeaders,omitempty"`
 	RouteByHTTPUser   string           `json:"routeByHTTPUser,omitempty"`
 }
 
@@ -304,6 +305,7 @@ func (c *HTTPProxyConfig) MarshalToMsg(m *msg.NewProxy) {
 	m.HTTPUser = c.HTTPUser
 	m.HTTPPwd = c.HTTPPassword
 	m.Headers = c.RequestHeaders.Set
+	m.ResponseHeaders = c.ResponseHeaders.Set
 	m.RouteByHTTPUser = c.RouteByHTTPUser
 }
 
@@ -317,6 +319,7 @@ func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
 	c.HTTPUser = m.HTTPUser
 	c.HTTPPassword = m.HTTPPwd
 	c.RequestHeaders.Set = m.Headers
+	c.ResponseHeaders.Set = m.ResponseHeaders
 	c.RouteByHTTPUser = m.RouteByHTTPUser
 }
 
diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go
index ab6d7d2852a..a6344d08fe3 100644
--- a/pkg/msg/msg.go
+++ b/pkg/msg/msg.go
@@ -121,6 +121,7 @@ type NewProxy struct {
 	HTTPPwd           string            `json:"http_pwd,omitempty"`
 	HostHeaderRewrite string            `json:"host_header_rewrite,omitempty"`
 	Headers           map[string]string `json:"headers,omitempty"`
+	ResponseHeaders   map[string]string `json:"response_headers,omitempty"`
 	RouteByHTTPUser   string            `json:"route_by_http_user,omitempty"`
 
 	// stcp, sudp, xtcp
diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go
index 518f45476a4..30f9631e31e 100644
--- a/pkg/util/vhost/http.go
+++ b/pkg/util/vhost/http.go
@@ -63,9 +63,9 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
 			req := r.Out
 			req.URL.Scheme = "http"
 			reqRouteInfo := req.Context().Value(RouteInfoKey).(*RequestRouteInfo)
-			oldHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
+			originalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
 
-			rc := rp.GetRouteConfig(oldHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
+			rc := req.Context().Value(RouteConfigKey).(*RouteConfig)
 			if rc != nil {
 				if rc.RewriteHost != "" {
 					req.Host = rc.RewriteHost
@@ -77,7 +77,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
 					endpoint, _ = rc.ChooseEndpointFn()
 					reqRouteInfo.Endpoint = endpoint
 					log.Tracef("choose endpoint name [%s] for http request host [%s] path [%s] httpuser [%s]",
-						endpoint, oldHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
+						endpoint, originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
 				}
 				// Set {domain}.{location}.{routeByHTTPUser}.{endpoint} as URL host here to let http transport reuse connections.
 				req.URL.Host = rc.Domain + "." +
@@ -92,6 +92,15 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
 				req.URL.Host = req.Host
 			}
 		},
+		ModifyResponse: func(r *http.Response) error {
+			rc := r.Request.Context().Value(RouteConfigKey).(*RouteConfig)
+			if rc != nil {
+				for k, v := range rc.ResponseHeaders {
+					r.Header.Set(k, v)
+				}
+			}
+			return nil
+		},
 		// Create a connection to one proxy routed by route policy.
 		Transport: &http.Transport{
 			ResponseHeaderTimeout: rp.responseHeaderTimeout,
@@ -157,14 +166,6 @@ func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser str
 	return nil
 }
 
-func (rp *HTTPReverseProxy) GetHeaders(domain, location, routeByHTTPUser string) (headers map[string]string) {
-	vr, ok := rp.getVhost(domain, location, routeByHTTPUser)
-	if ok {
-		headers = vr.payload.(*RouteConfig).Headers
-	}
-	return
-}
-
 // CreateConnection create a new connection by route config
 func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) {
 	host, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
@@ -305,8 +306,13 @@ func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Requ
 		RemoteAddr: req.RemoteAddr,
 		URLHost:    req.URL.Host,
 	}
+
+	originalHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host)
+	rc := rp.GetRouteConfig(originalHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser)
+
 	newctx := req.Context()
 	newctx = context.WithValue(newctx, RouteInfoKey, reqRouteInfo)
+	newctx = context.WithValue(newctx, RouteConfigKey, rc)
 	return req.Clone(newctx)
 }
 
diff --git a/pkg/util/vhost/vhost.go b/pkg/util/vhost/vhost.go
index b1370599a70..e62a1caf354 100644
--- a/pkg/util/vhost/vhost.go
+++ b/pkg/util/vhost/vhost.go
@@ -29,7 +29,8 @@ import (
 type RouteInfo string
 
 const (
-	RouteInfoKey RouteInfo = "routeInfo"
+	RouteInfoKey   RouteInfo = "routeInfo"
+	RouteConfigKey RouteInfo = "routeConfig"
 )
 
 type RequestRouteInfo struct {
@@ -113,6 +114,7 @@ type RouteConfig struct {
 	Username        string
 	Password        string
 	Headers         map[string]string
+	ResponseHeaders map[string]string
 	RouteByHTTPUser string
 
 	CreateConnFn           CreateConnFunc
diff --git a/server/proxy/http.go b/server/proxy/http.go
index cd4c4b968bc..9a02dcddce3 100644
--- a/server/proxy/http.go
+++ b/server/proxy/http.go
@@ -58,6 +58,7 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
 		RewriteHost:     pxy.cfg.HostHeaderRewrite,
 		RouteByHTTPUser: pxy.cfg.RouteByHTTPUser,
 		Headers:         pxy.cfg.RequestHeaders.Set,
+		ResponseHeaders: pxy.cfg.ResponseHeaders.Set,
 		Username:        pxy.cfg.HTTPUser,
 		Password:        pxy.cfg.HTTPPassword,
 		CreateConnFn:    pxy.GetRealConn,


Test patch:
diff --git a/test/e2e/v1/basic/http.go b/test/e2e/v1/basic/http.go
index 4883263efae..c37e84e719d 100644
--- a/test/e2e/v1/basic/http.go
+++ b/test/e2e/v1/basic/http.go
@@ -267,7 +267,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
 			Ensure()
 	})
 
-	ginkgo.It("Modify headers", func() {
+	ginkgo.It("Modify request headers", func() {
 		vhostHTTPPort := f.AllocPort()
 		serverConf := getDefaultServerConf(vhostHTTPPort)
 
@@ -292,7 +292,6 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
 
 		f.RunProcesses([]string{serverConf}, []string{clientConf})
 
-		// not set auth header
 		framework.NewRequestExpect(f).Port(vhostHTTPPort).
 			RequestModify(func(r *request.Request) {
 				r.HTTP().HTTPHost("normal.example.com")
@@ -301,6 +300,40 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
 			Ensure()
 	})
 
+	ginkgo.It("Modify response headers", func() {
+		vhostHTTPPort := f.AllocPort()
+		serverConf := getDefaultServerConf(vhostHTTPPort)
+
+		localPort := f.AllocPort()
+		localServer := httpserver.New(
+			httpserver.WithBindPort(localPort),
+			httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				w.WriteHeader(200)
+			})),
+		)
+		f.RunServer("", localServer)
+
+		clientConf := consts.DefaultClientConfig
+		clientConf += fmt.Sprintf(`
+			[[proxies]]
+			name = "test"
+			type = "http"
+			localPort = %d
+			customDomains = ["normal.example.com"]
+			responseHeaders.set.x-from-where = "frp"
+			`, localPort)
+
+		f.RunProcesses([]string{serverConf}, []string{clientConf})
+
+		framework.NewRequestExpect(f).Port(vhostHTTPPort).
+			RequestModify(func(r *request.Request) {
+				r.HTTP().HTTPHost("normal.example.com")
+			}).
+			Ensure(func(res *request.Response) bool {
+				return res.Header.Get("X-From-Where") == "frp"
+			})
+	})
+
 	ginkgo.It("Host Header Rewrite", func() {
 		vhostHTTPPort := f.AllocPort()
 		serverConf := getDefaultServerConf(vhostHTTPPort)