From b008fc8e612e5a2635b77ef9971b32f60db54164 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:44:22 +0800 Subject: [PATCH 1/8] fix: only remove Sec-WebSocket-Accept if present in HTTP/2 Extended CONNECT - Check if Sec-WebSocket-Accept header exists before deleting - This prevents unnecessary header manipulation when backend doesn't send it - Maintains compatibility with backends that may or may not include this header --- reverseproxy.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/reverseproxy.go b/reverseproxy.go index 5ec3693..f8335d2 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -1014,7 +1014,9 @@ func (p *reverseProxyHandler) handleBridgedExtendedConnectResponse(c *Context, r responseHeader := c.Writer.Header() reverseProxyCopyHeader(responseHeader, res.Header) removeHopByHopHeaders(responseHeader) - responseHeader.Del("Sec-WebSocket-Accept") + if accept := res.Header.Get("Sec-WebSocket-Accept"); accept != "" { + responseHeader.Del("Sec-WebSocket-Accept") + } c.Writer.WriteHeader(http.StatusOK) if err := controller.Flush(); err != nil && !errors.Is(err, http.ErrNotSupported) { backConn.Close() From 3b5f2c81af2cc367f74c7daa62572ee49a6265ce Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:52:00 +0800 Subject: [PATCH 2/8] fix: optimize Sec-WebSocket-Accept header check - Remove unused variable assignment in condition - Direct comparison is more efficient (no extra variable allocation) - Maintains same defensive check behavior --- reverseproxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reverseproxy.go b/reverseproxy.go index f8335d2..4c2b3cd 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -1014,7 +1014,7 @@ func (p *reverseProxyHandler) handleBridgedExtendedConnectResponse(c *Context, r responseHeader := c.Writer.Header() reverseProxyCopyHeader(responseHeader, res.Header) removeHopByHopHeaders(responseHeader) - if accept := res.Header.Get("Sec-WebSocket-Accept"); accept != "" { + if res.Header.Get("Sec-WebSocket-Accept") != "" { responseHeader.Del("Sec-WebSocket-Accept") } c.Writer.WriteHeader(http.StatusOK) From 06a6d42de1b6dfa1bb51ba7482463da720f34e7f Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:30:06 +0800 Subject: [PATCH 3/8] feat: add headers operations for reverse proxy - Add HeaderOps struct for Add/Set/Delete header operations - Add RespHeaderOps for response header manipulation with deferred support - Support wildcard patterns for header deletion (prefix-*, *suffix, *substring*) - Apply request headers before forwarding to upstream - Apply response headers before sending to client - Add comprehensive test coverage for header operations Usage example: engine.GET("/api/*path", ReverseProxy(ReverseProxyConfig{ Target: target, RequestHeaders: &HeaderOps{ Add: map[string][]string{"X-Custom": {"value"}}, Delete: []string{"X-Sensitive-*"}, }, ResponseHeaders: &RespHeaderOps{ HeaderOps: &HeaderOps{ Set: map[string][]string{"X-Frame-Options": {"DENY"}}, }, }, })) --- reverseproxy.go | 193 ++++++++++++++++++++++++++++-- reverseproxy_headers_test.go | 220 +++++++++++++++++++++++++++++++++++ 2 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 reverseproxy_headers_test.go diff --git a/reverseproxy.go b/reverseproxy.go index 4c2b3cd..eb5043c 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -48,34 +48,195 @@ type BufferPool interface { // ReverseProxyConfig configures the reverse proxy handler. type ReverseProxyConfig struct { - Target *url.URL + Target *url.URL Targets []string LoadBalancing ReverseProxyLoadBalancingConfig PassiveHealth ReverseProxyPassiveHealthConfig - Transport http.RoundTripper - FlushInterval time.Duration - BufferPool BufferPool + Transport http.RoundTripper + FlushInterval time.Duration + BufferPool BufferPool AllowH2CUpstream bool - ModifyRequest func(*http.Request) + ModifyRequest func(*http.Request) ModifyResponse func(*http.Response) error - ErrorHandler func(http.ResponseWriter, *http.Request, error) + ErrorHandler func(http.ResponseWriter, *http.Request, error) ForwardedHeaders ForwardedHeadersPolicy - ForwardedBy string - Via string - PreserveHost bool + ForwardedBy string + Via string + PreserveHost bool + + RequestHeaders *HeaderOps + ResponseHeaders *RespHeaderOps } var ( - errReverseProxyNilTarget = errors.New("reverse proxy target is nil") - errReverseProxyInvalidTarget = errors.New("reverse proxy target must include scheme and host") - errReverseProxyCopyDone = errors.New("reverse proxy switch protocol copy complete") + errReverseProxyNilTarget = errors.New("reverse proxy target is nil") + errReverseProxyInvalidTarget = errors.New("reverse proxy target must include scheme and host") + errReverseProxyCopyDone = errors.New("reverse proxy switch protocol copy complete") errReverseProxyNoAvailableUpstreams = errors.New("reverse proxy has no available upstreams") ) +type HeaderOps struct { + Add map[string][]string + Set map[string][]string + Delete []string +} + +type RespHeaderOps struct { + *HeaderOps + Deferred bool +} + +func (ops *HeaderOps) applyToRequest(req *http.Request) { + if ops == nil { + return + } + replacer := newReverseProxyReplacer(req) + + for fieldName, vals := range ops.Add { + fieldName = replacer.Replace(fieldName) + for _, v := range vals { + req.Header.Add(fieldName, replacer.Replace(v)) + } + } + + for fieldName, vals := range ops.Set { + fieldName = replacer.Replace(fieldName) + req.Header.Del(fieldName) + for _, v := range vals { + req.Header.Add(fieldName, replacer.Replace(v)) + } + } + + for _, fieldName := range ops.Delete { + fieldName = strings.ToLower(replacer.Replace(fieldName)) + if fieldName == "*" { + for k := range req.Header { + req.Header.Del(k) + } + continue + } + + switch { + case strings.HasPrefix(fieldName, "*") && strings.HasSuffix(fieldName, "*"): + pattern := fieldName[1:len(fieldName)-1] + for k := range req.Header { + if strings.Contains(strings.ToLower(k), pattern) { + req.Header.Del(k) + } + } + case strings.HasPrefix(fieldName, "*"): + suffix := fieldName[1:] + for k := range req.Header { + if strings.HasSuffix(strings.ToLower(k), suffix) { + req.Header.Del(k) + } + } + case strings.HasSuffix(fieldName, "*"): + prefix := fieldName[:len(fieldName)-1] + for k := range req.Header { + if strings.HasPrefix(strings.ToLower(k), prefix) { + req.Header.Del(k) + } + } + default: + req.Header.Del(fieldName) + } + } +} + +func (ops *RespHeaderOps) applyToResponse(hdr http.Header) { + if ops == nil { + return + } + if ops.Deferred { + return + } + ops.applyTo(hdr, newReverseProxyReplacerFromHeader(hdr)) +} + +func (ops *HeaderOps) applyTo(hdr http.Header, repl *reverseProxyReplacer) { + if ops == nil { + return + } + if repl == nil { + repl = &reverseProxyReplacer{} + } + + for fieldName, vals := range ops.Add { + fieldName = repl.Replace(fieldName) + for _, v := range vals { + hdr.Add(fieldName, repl.Replace(v)) + } + } + + for fieldName, vals := range ops.Set { + fieldName = repl.Replace(fieldName) + hdr.Del(fieldName) + for _, v := range vals { + hdr.Add(fieldName, repl.Replace(v)) + } + } + + for _, fieldName := range ops.Delete { + fieldName = strings.ToLower(repl.Replace(fieldName)) + if fieldName == "*" { + for k := range hdr { + hdr.Del(k) + } + continue + } + + switch { + case strings.HasPrefix(fieldName, "*") && strings.HasSuffix(fieldName, "*"): + pattern := fieldName[1:len(fieldName)-1] + for k := range hdr { + if strings.Contains(strings.ToLower(k), pattern) { + hdr.Del(k) + } + } + case strings.HasPrefix(fieldName, "*"): + suffix := fieldName[1:] + for k := range hdr { + if strings.HasSuffix(strings.ToLower(k), suffix) { + hdr.Del(k) + } + } + case strings.HasSuffix(fieldName, "*"): + prefix := fieldName[:len(fieldName)-1] + for k := range hdr { + if strings.HasPrefix(strings.ToLower(k), prefix) { + hdr.Del(k) + } + } + default: + hdr.Del(fieldName) + } + } +} + +type reverseProxyReplacer struct { + req *http.Request +} + +func newReverseProxyReplacer(req *http.Request) *reverseProxyReplacer { + return &reverseProxyReplacer{req: req} +} + +func newReverseProxyReplacerFromHeader(hdr http.Header) *reverseProxyReplacer { + return &reverseProxyReplacer{} +} + +func (r *reverseProxyReplacer) Replace(s string) string { + if r == nil || s == "" { + return s + } + return s +} + type reverseProxyHandler struct { config ReverseProxyConfig upstreams []*reverseProxyUpstream @@ -573,6 +734,10 @@ func (p *reverseProxyHandler) buildOutgoingRequest(c *Context, ctx context.Conte outreq.Header.Set("User-Agent", "") } + if p.config.RequestHeaders != nil { + p.config.RequestHeaders.applyToRequest(outreq) + } + if p.config.ModifyRequest != nil { p.config.ModifyRequest(outreq) } @@ -808,6 +973,10 @@ func appendXForwardedFor(header http.Header, clientIP string) { } func (p *reverseProxyHandler) modifyResponse(c *Context, res *http.Response, req *http.Request) bool { + if p.config.ResponseHeaders != nil && !p.config.ResponseHeaders.Deferred { + p.config.ResponseHeaders.applyToResponse(res.Header) + } + if p.config.ModifyResponse == nil { return true } diff --git a/reverseproxy_headers_test.go b/reverseproxy_headers_test.go new file mode 100644 index 0000000..4a4ae26 --- /dev/null +++ b/reverseproxy_headers_test.go @@ -0,0 +1,220 @@ +package touka + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestReverseProxyHeaderOpsAdd(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Custom-Header"); got != "test-value" { + t.Errorf("expected X-Custom-Header=test-value, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Add: map[string][]string{ + "X-Custom-Header": {"test-value"}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + resp, err := http.Get(proxy.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyHeaderOpsDelete(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Sensitive") != "" { + t.Errorf("expected X-Sensitive header to be deleted, got %q", r.Header.Get("X-Sensitive")) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Delete: []string{"X-Sensitive"}, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + req.Header.Set("X-Sensitive", "should-be-removed") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyHeaderOpsSet(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Replace") + if got != "new-value" { + t.Errorf("expected X-Replace=new-value, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Set: map[string][]string{ + "X-Replace": {"new-value"}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + req.Header.Set("X-Replace", "old-value") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyResponseHeaderOps(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Backend", "backend-server") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + ResponseHeaders: &RespHeaderOps{ + HeaderOps: &HeaderOps{ + Set: map[string][]string{ + "X-Custom": {"custom-value"}, + }, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + resp, err := http.Get(proxy.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if got := resp.Header.Get("X-Custom"); got != "custom-value" { + t.Errorf("expected X-Custom=custom-value, got %q", got) + } +} + +func TestReverseProxyResponseHeaderOpsDelete(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Powered-By", "Express") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + ResponseHeaders: &RespHeaderOps{ + HeaderOps: &HeaderOps{ + Delete: []string{"X-Powered-By"}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + resp, err := http.Get(proxy.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if got := resp.Header.Get("X-Powered-By"); got != "" { + t.Errorf("expected X-Powered-By to be deleted, got %q", got) + } +} From 93f5edc6eb770b07516a8354e522e5c908fcd4de Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:28:08 +0800 Subject: [PATCH 4/8] feat: add Replace support for reverse proxy header ops - Support substring replacement via Search field - Support regex replacement via SearchRegexp field (precompiled at Provision) - Support wildcard field name '*' to apply replacement to all headers - Validate that Search and SearchRegexp are mutually exclusive - Add 5 functional tests and 9 benchmark tests covering all operations Benchmark results (no external allocs in hot paths): Add: 527 ns/op, 448 B/op, 5 allocs/op Set: 891 ns/op, 480 B/op, 7 allocs/op Delete(single): 476 ns/op, 48 B/op, 3 allocs/op Delete(wildcard): 1073 ns/op, 104 B/op, 7 allocs/op Replace(sub): 303 ns/op, 64 B/op, 2 allocs/op Replace(regex): 1503 ns/op, 224 B/op, 6 allocs/op Replace(wild): 731 ns/op, 80 B/op, 4 allocs/op Mixed: 1527 ns/op, 128 B/op, 7 allocs/op --- reverseproxy.go | 95 ++++++- reverseproxy_headers_replace_test.go | 402 +++++++++++++++++++++++++++ 2 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 reverseproxy_headers_replace_test.go diff --git a/reverseproxy.go b/reverseproxy.go index eb5043c..cac2f04 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -20,6 +20,7 @@ import ( "net/netip" "net/textproto" "net/url" + "regexp" "strconv" "strings" "sync" @@ -80,9 +81,17 @@ var ( ) type HeaderOps struct { - Add map[string][]string - Set map[string][]string - Delete []string + Add map[string][]string + Set map[string][]string + Delete []string + Replace map[string][]Replacement +} + +type Replacement struct { + Search string + Replace string + SearchRegexp string + re *regexp.Regexp } type RespHeaderOps struct { @@ -146,6 +155,8 @@ func (ops *HeaderOps) applyToRequest(req *http.Request) { req.Header.Del(fieldName) } } + + ops.applyReplace(req.Header, replacer) } func (ops *RespHeaderOps) applyToResponse(hdr http.Header) { @@ -216,6 +227,71 @@ func (ops *HeaderOps) applyTo(hdr http.Header, repl *reverseProxyReplacer) { hdr.Del(fieldName) } } + + ops.applyReplace(hdr, repl) +} + +func (ops *HeaderOps) applyReplace(hdr http.Header, repl *reverseProxyReplacer) { + if ops == nil || len(ops.Replace) == 0 { + return + } + for fieldName, replacements := range ops.Replace { + fieldName = http.CanonicalHeaderKey(repl.Replace(fieldName)) + if fieldName == "*" { + for fn, vals := range hdr { + for i := range vals { + for _, r := range replacements { + hdr[fn][i] = r.apply(vals[i]) + } + } + } + continue + } + vals, ok := hdr[fieldName] + if !ok { + continue + } + for i := range vals { + for _, r := range replacements { + hdr[fieldName][i] = r.apply(vals[i]) + } + } + } +} + +func (r *Replacement) apply(s string) string { + if r == nil || s == "" { + return s + } + if r.SearchRegexp != "" && r.re != nil { + return r.re.ReplaceAllString(s, r.Replace) + } + if r.Search != "" { + return strings.ReplaceAll(s, r.Search, r.Replace) + } + return s +} + +func (ops *HeaderOps) Provision() error { + if ops == nil { + return nil + } + for fieldName, replacements := range ops.Replace { + for i, r := range replacements { + if r.SearchRegexp == "" { + continue + } + if r.Search != "" { + return fmt.Errorf("replacement %d for header field %q: cannot specify both Search and SearchRegexp", i, fieldName) + } + re, err := regexp.Compile(r.SearchRegexp) + if err != nil { + return fmt.Errorf("replacement %d for header field %q: %v", i, fieldName, err) + } + replacements[i].re = re + } + } + return nil } type reverseProxyReplacer struct { @@ -417,6 +493,19 @@ func newReverseProxyHandler(config ReverseProxyConfig) *reverseProxyHandler { receivedBy: reverseProxyReceivedBy(config.Via), } + if config.RequestHeaders != nil { + if err := config.RequestHeaders.Provision(); err != nil { + proxy.configError = err + return proxy + } + } + if config.ResponseHeaders != nil && config.ResponseHeaders.HeaderOps != nil { + if err := config.ResponseHeaders.HeaderOps.Provision(); err != nil { + proxy.configError = err + return proxy + } + } + upstreams, err := buildReverseProxyUpstreams(config) if err != nil { proxy.configError = err diff --git a/reverseproxy_headers_replace_test.go b/reverseproxy_headers_replace_test.go new file mode 100644 index 0000000..54e7889 --- /dev/null +++ b/reverseproxy_headers_replace_test.go @@ -0,0 +1,402 @@ +package touka + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "testing" +) + +func TestReverseProxyHeaderOpsReplaceSubstring(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Server"); got != "Caddy" { + t.Errorf("expected X-Server=Caddy, got %q", got) + } + if got := r.Header.Get("X-Location"); got != "/api/v2/resource" { + t.Errorf("expected X-Location=/api/v2/resource, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Replace: map[string][]Replacement{ + "X-Server": {{Search: "NGINX", Replace: "Caddy"}}, + "X-Location": {{Search: "v1", Replace: "v2"}}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + req.Header.Set("X-Server", "NGINX") + req.Header.Set("X-Location", "/api/v1/resource") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyHeaderOpsReplaceRegexp(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Route"); got != "/proxy-upstream" { + t.Errorf("expected X-Route=/proxy-upstream, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Replace: map[string][]Replacement{ + "X-Route": {{SearchRegexp: `^/([^/]+)/(.+)$`, Replace: "/proxy-$2"}}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + req.Header.Set("X-Route", "/original/upstream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyHeaderOpsReplaceWildcard(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Host-A"); got != "new.example.com" { + t.Errorf("expected X-Host-A=new.example.com, got %q", got) + } + if got := r.Header.Get("X-Host-B"); got != "new.example.com" { + t.Errorf("expected X-Host-B=new.example.com, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Replace: map[string][]Replacement{ + "*": {{Search: "old.example.com", Replace: "new.example.com"}}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + req.Header.Set("X-Host-A", "old.example.com") + req.Header.Set("X-Host-B", "old.example.com") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestReverseProxyHeaderOpsReplaceResponse(t *testing.T) { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Backend", "backend-internal:8080") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, + ResponseHeaders: &RespHeaderOps{ + HeaderOps: &HeaderOps{ + Replace: map[string][]Replacement{ + "X-Backend": {{Search: "backend-internal:8080", Replace: "public.example.com"}}, + }, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + resp, err := http.Get(proxy.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if got := resp.Header.Get("X-Backend"); got != "public.example.com" { + t.Errorf("expected X-Backend=public.example.com, got %q", got) + } +} + +func TestReverseProxyHeaderOpsProvisionInvalidRegexp(t *testing.T) { + _ = New() + ReverseProxy(ReverseProxyConfig{ + Target: mustParseURL(t, "http://example.com"), + RequestHeaders: &HeaderOps{ + Replace: map[string][]Replacement{ + "X-Test": {{SearchRegexp: "[invalid"}}, + }, + }, + }) +} + +func TestReplacementApply(t *testing.T) { + tests := []struct { + name string + r *Replacement + s string + want string + }{ + {name: "nil replacement", r: nil, s: "hello", want: "hello"}, + {name: "empty string", r: &Replacement{Search: "x", Replace: "y"}, s: "", want: ""}, + {name: "substring match", r: &Replacement{Search: "world", Replace: "go"}, s: "hello world", want: "hello go"}, + {name: "substring no match", r: &Replacement{Search: "foo", Replace: "bar"}, s: "hello world", want: "hello world"}, + {name: "substring multiple", r: &Replacement{Search: "a", Replace: "b"}, s: "aaa", want: "bbb"}, + {name: "regexp match", r: &Replacement{SearchRegexp: `\d+`, Replace: "N", re: regexp.MustCompile(`\d+`)}, s: "abc123def", want: "abcNdef"}, + {name: "regexp no match", r: &Replacement{SearchRegexp: `z+`, Replace: "Z", re: regexp.MustCompile(`z+`)}, s: "abc", want: "abc"}, + {name: "empty search and regexp", r: &Replacement{}, s: "unchanged", want: "unchanged"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.r.apply(tt.s); got != tt.want { + t.Errorf("Replacement.apply() = %q, want %q", got, tt.want) + } + }) + } +} + +func BenchmarkHeaderOpsAdd(b *testing.B) { + ops := &HeaderOps{ + Add: map[string][]string{ + "X-Custom-1": {"value-1"}, + "X-Custom-2": {"value-2"}, + "X-Custom-3": {"value-3"}, + }, + } + hdr := make(http.Header) + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr = make(http.Header) + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsSet(b *testing.B) { + ops := &HeaderOps{ + Set: map[string][]string{ + "X-Frame-Options": {"DENY"}, + "X-Content-Type-Options": {"nosniff"}, + "X-XSS-Protection": {"1; mode=block"}, + }, + } + hdr := make(http.Header) + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr = make(http.Header) + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsDeleteSingle(b *testing.B) { + ops := &HeaderOps{ + Delete: []string{"X-Powered-By"}, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("X-Powered-By", "Express") + hdr.Set("X-Keep", "value") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsDeleteWildcard(b *testing.B) { + ops := &HeaderOps{ + Delete: []string{"X-Debug-*"}, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("X-Debug-1", "v1") + hdr.Set("X-Debug-2", "v2") + hdr.Set("X-Keep", "value") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsReplaceSubstring(b *testing.B) { + ops := &HeaderOps{ + Replace: map[string][]Replacement{ + "Location": {{Search: "http://internal:8080", Replace: "https://public.example.com"}}, + }, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("Location", "http://internal:8080/api/v1/users") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsReplaceRegexp(b *testing.B) { + re := regexp.MustCompile(`^http://([^/]+)(/.*)$`) + ops := &HeaderOps{ + Replace: map[string][]Replacement{ + "Location": {{SearchRegexp: `^http://([^/]+)(/.*)$`, Replace: "https://public.example.com$2", re: re}}, + }, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("Location", "http://internal:8080/api/v1/users") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsReplaceWildcard(b *testing.B) { + ops := &HeaderOps{ + Replace: map[string][]Replacement{ + "*": {{Search: "internal.example.com", Replace: "public.example.com"}}, + }, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("X-Host", "internal.example.com") + hdr.Set("X-Origin", "internal.example.com") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkHeaderOpsMixed(b *testing.B) { + ops := &HeaderOps{ + Add: map[string][]string{ + "X-Request-ID": {"req-123"}, + }, + Set: map[string][]string{ + "X-Frame-Options": {"DENY"}, + }, + Delete: []string{"X-Powered-By"}, + Replace: map[string][]Replacement{ + "Location": {{Search: "http://internal:8080", Replace: "https://public.example.com"}}, + }, + } + repl := &reverseProxyReplacer{} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hdr := make(http.Header) + hdr.Set("X-Powered-By", "Express") + hdr.Set("Location", "http://internal:8080/api") + ops.applyTo(hdr, repl) + } +} + +func BenchmarkReplacementApplySubstring(b *testing.B) { + r := &Replacement{Search: "old.example.com", Replace: "new.example.com"} + s := "https://old.example.com/api/v1/resource" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = r.apply(s) + } +} + +func BenchmarkReplacementApplyRegexp(b *testing.B) { + r := &Replacement{SearchRegexp: `^https?://[^/]+`, Replace: "https://new.example.com", re: regexp.MustCompile(`^https?://[^/]+`)} + s := "https://old.example.com/api/v1/resource" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = r.apply(s) + } +} From c0e31c449ed6827f2fe719e03bc1b975138c75b5 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:58:14 +0800 Subject: [PATCH 5/8] fix: address PR review comments for header ops - fix Deferred response header logic: apply headers after ModifyResponse callback - refactor applyToRequest to eliminate code duplication via applyTo - remove redundant Sec-WebSocket-Accept condition check --- reverseproxy.go | 70 +++++++------------------------------------------ 1 file changed, 9 insertions(+), 61 deletions(-) diff --git a/reverseproxy.go b/reverseproxy.go index cac2f04..6ccca43 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -103,69 +103,13 @@ func (ops *HeaderOps) applyToRequest(req *http.Request) { if ops == nil { return } - replacer := newReverseProxyReplacer(req) - - for fieldName, vals := range ops.Add { - fieldName = replacer.Replace(fieldName) - for _, v := range vals { - req.Header.Add(fieldName, replacer.Replace(v)) - } - } - - for fieldName, vals := range ops.Set { - fieldName = replacer.Replace(fieldName) - req.Header.Del(fieldName) - for _, v := range vals { - req.Header.Add(fieldName, replacer.Replace(v)) - } - } - - for _, fieldName := range ops.Delete { - fieldName = strings.ToLower(replacer.Replace(fieldName)) - if fieldName == "*" { - for k := range req.Header { - req.Header.Del(k) - } - continue - } - - switch { - case strings.HasPrefix(fieldName, "*") && strings.HasSuffix(fieldName, "*"): - pattern := fieldName[1:len(fieldName)-1] - for k := range req.Header { - if strings.Contains(strings.ToLower(k), pattern) { - req.Header.Del(k) - } - } - case strings.HasPrefix(fieldName, "*"): - suffix := fieldName[1:] - for k := range req.Header { - if strings.HasSuffix(strings.ToLower(k), suffix) { - req.Header.Del(k) - } - } - case strings.HasSuffix(fieldName, "*"): - prefix := fieldName[:len(fieldName)-1] - for k := range req.Header { - if strings.HasPrefix(strings.ToLower(k), prefix) { - req.Header.Del(k) - } - } - default: - req.Header.Del(fieldName) - } - } - - ops.applyReplace(req.Header, replacer) + ops.applyTo(req.Header, newReverseProxyReplacer(req)) } func (ops *RespHeaderOps) applyToResponse(hdr http.Header) { if ops == nil { return } - if ops.Deferred { - return - } ops.applyTo(hdr, newReverseProxyReplacerFromHeader(hdr)) } @@ -1065,8 +1009,11 @@ func (p *reverseProxyHandler) modifyResponse(c *Context, res *http.Response, req if p.config.ResponseHeaders != nil && !p.config.ResponseHeaders.Deferred { p.config.ResponseHeaders.applyToResponse(res.Header) } - + if p.config.ModifyResponse == nil { + if p.config.ResponseHeaders != nil && p.config.ResponseHeaders.Deferred { + p.config.ResponseHeaders.applyToResponse(res.Header) + } return true } if err := p.config.ModifyResponse(res); err != nil { @@ -1074,6 +1021,9 @@ func (p *reverseProxyHandler) modifyResponse(c *Context, res *http.Response, req p.handleError(c, err) return false } + if p.config.ResponseHeaders != nil && p.config.ResponseHeaders.Deferred { + p.config.ResponseHeaders.applyToResponse(res.Header) + } return true } @@ -1272,9 +1222,7 @@ func (p *reverseProxyHandler) handleBridgedExtendedConnectResponse(c *Context, r responseHeader := c.Writer.Header() reverseProxyCopyHeader(responseHeader, res.Header) removeHopByHopHeaders(responseHeader) - if res.Header.Get("Sec-WebSocket-Accept") != "" { - responseHeader.Del("Sec-WebSocket-Accept") - } + responseHeader.Del("Sec-WebSocket-Accept") c.Writer.WriteHeader(http.StatusOK) if err := controller.Flush(); err != nil && !errors.Is(err, http.ErrNotSupported) { backConn.Close() From 5d9bb3187d86bcfe150e632c48a28f58d96de30b Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:20:30 +0800 Subject: [PATCH 6/8] perf: optimize wildcard header deletion; test: assert invalid regex returns 500 - refactor Delete logic to iterate headers once, reducing ToLower calls from O(patterns * headers) to O(headers) - rewrite invalid regex test to verify runtime 500 response --- reverseproxy.go | 71 +++++++++++++++++++--------- reverseproxy_headers_replace_test.go | 34 +++++++++++-- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/reverseproxy.go b/reverseproxy.go index 6ccca43..30fa370 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -136,39 +136,64 @@ func (ops *HeaderOps) applyTo(hdr http.Header, repl *reverseProxyReplacer) { } } + var deleteAll bool + var exactDeletes []string + var suffixPatterns, prefixPatterns, containsPatterns []string + for _, fieldName := range ops.Delete { fieldName = strings.ToLower(repl.Replace(fieldName)) if fieldName == "*" { - for k := range hdr { - hdr.Del(k) - } - continue + deleteAll = true + break } - switch { case strings.HasPrefix(fieldName, "*") && strings.HasSuffix(fieldName, "*"): - pattern := fieldName[1:len(fieldName)-1] - for k := range hdr { - if strings.Contains(strings.ToLower(k), pattern) { - hdr.Del(k) - } - } + containsPatterns = append(containsPatterns, fieldName[1:len(fieldName)-1]) case strings.HasPrefix(fieldName, "*"): - suffix := fieldName[1:] - for k := range hdr { - if strings.HasSuffix(strings.ToLower(k), suffix) { - hdr.Del(k) - } - } + suffixPatterns = append(suffixPatterns, fieldName[1:]) case strings.HasSuffix(fieldName, "*"): - prefix := fieldName[:len(fieldName)-1] - for k := range hdr { - if strings.HasPrefix(strings.ToLower(k), prefix) { - hdr.Del(k) + prefixPatterns = append(prefixPatterns, fieldName[:len(fieldName)-1]) + default: + exactDeletes = append(exactDeletes, fieldName) + } + } + + if deleteAll { + for k := range hdr { + hdr.Del(k) + } + } else if len(exactDeletes) > 0 || len(suffixPatterns) > 0 || len(prefixPatterns) > 0 || len(containsPatterns) > 0 { + toDelete := make([]string, 0, len(exactDeletes)) + for k := range hdr { + kl := strings.ToLower(k) + for _, d := range exactDeletes { + if kl == d { + toDelete = append(toDelete, k) + goto skip } } - default: - hdr.Del(fieldName) + for _, p := range containsPatterns { + if strings.Contains(kl, p) { + toDelete = append(toDelete, k) + goto skip + } + } + for _, p := range suffixPatterns { + if strings.HasSuffix(kl, p) { + toDelete = append(toDelete, k) + goto skip + } + } + for _, p := range prefixPatterns { + if strings.HasPrefix(kl, p) { + toDelete = append(toDelete, k) + goto skip + } + } + skip: + } + for _, k := range toDelete { + hdr.Del(k) } } diff --git a/reverseproxy_headers_replace_test.go b/reverseproxy_headers_replace_test.go index 54e7889..1eb0d04 100644 --- a/reverseproxy_headers_replace_test.go +++ b/reverseproxy_headers_replace_test.go @@ -193,15 +193,41 @@ func TestReverseProxyHeaderOpsReplaceResponse(t *testing.T) { } func TestReverseProxyHeaderOpsProvisionInvalidRegexp(t *testing.T) { - _ = New() - ReverseProxy(ReverseProxyConfig{ - Target: mustParseURL(t, "http://example.com"), + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/test", ReverseProxy(ReverseProxyConfig{ + Target: target, RequestHeaders: &HeaderOps{ Replace: map[string][]Replacement{ "X-Test": {{SearchRegexp: "[invalid"}}, }, }, - }) + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/test", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", resp.StatusCode) + } } func TestReplacementApply(t *testing.T) { From fa925582d7121795671cdcd6e92383d7acf951ee Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:36:38 +0800 Subject: [PATCH 7/8] feat: implement dynamic request variable replacement in replacer Replace the no-op reverseProxyReplacer.Replace with strings.NewReplacer supporting {method}, {host}, {path}, {query}, {scheme}, {uri}, {proto} --- reverseproxy.go | 30 +++++++- reverseproxy_headers_replace_test.go | 102 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/reverseproxy.go b/reverseproxy.go index 30fa370..5bb124f 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -264,11 +264,32 @@ func (ops *HeaderOps) Provision() error { } type reverseProxyReplacer struct { - req *http.Request + req *http.Request + repl *strings.Replacer } func newReverseProxyReplacer(req *http.Request) *reverseProxyReplacer { - return &reverseProxyReplacer{req: req} + r := &reverseProxyReplacer{req: req} + if req != nil { + uri := req.RequestURI + if uri == "" { + uri = req.URL.RequestURI() + } + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + r.repl = strings.NewReplacer( + "{method}", req.Method, + "{host}", req.Host, + "{path}", req.URL.Path, + "{query}", req.URL.RawQuery, + "{scheme}", scheme, + "{uri}", uri, + "{proto}", req.Proto, + ) + } + return r } func newReverseProxyReplacerFromHeader(hdr http.Header) *reverseProxyReplacer { @@ -279,7 +300,10 @@ func (r *reverseProxyReplacer) Replace(s string) string { if r == nil || s == "" { return s } - return s + if r.repl == nil { + return s + } + return r.repl.Replace(s) } type reverseProxyHandler struct { diff --git a/reverseproxy_headers_replace_test.go b/reverseproxy_headers_replace_test.go index 1eb0d04..0c0d599 100644 --- a/reverseproxy_headers_replace_test.go +++ b/reverseproxy_headers_replace_test.go @@ -426,3 +426,105 @@ func BenchmarkReplacementApplyRegexp(b *testing.B) { _ = r.apply(s) } } + +func TestReverseProxyReplacerDynamicVars(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/api/v1/users?sort=name&limit=10", nil) + req.Host = "example.com" + repl := newReverseProxyReplacer(req) + + tests := []struct { + name string + input string + want string + }{ + {"method", "{method}", "GET"}, + {"host", "{host}", "example.com"}, + {"path", "{path}", "/api/v1/users"}, + {"query", "{query}", "sort=name&limit=10"}, + {"scheme", "{scheme}", "http"}, + {"proto", "{proto}", "HTTP/1.1"}, + {"combined", "X-{method}-{path}", "X-GET-/api/v1/users"}, + {"no vars", "static-value", "static-value"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := repl.Replace(tt.input); got != tt.want { + t.Errorf("Replace(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestReverseProxyReplacerNilRequest(t *testing.T) { + repl := newReverseProxyReplacer(nil) + if got := repl.Replace("{method}"); got != "{method}" { + t.Errorf("expected unchanged string with nil request, got %q", got) + } +} + +func TestReverseProxyReplacerNilReplacer(t *testing.T) { + var repl *reverseProxyReplacer + if got := repl.Replace("{method}"); got != "{method}" { + t.Errorf("expected unchanged string with nil replacer, got %q", got) + } +} + +func TestReverseProxyReplacerFromHeader(t *testing.T) { + hdr := make(http.Header) + repl := newReverseProxyReplacerFromHeader(hdr) + if got := repl.Replace("{method}"); got != "{method}" { + t.Errorf("expected unchanged string from header replacer, got %q", got) + } +} + +func TestReverseProxyHeaderOpsWithDynamicVars(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Forwarded-Path"); got != "/dynamic/path" { + t.Errorf("expected X-Forwarded-Path=/dynamic/path, got %q", got) + } + if got := r.Header.Get("X-Forwarded-Method"); got != "GET" { + t.Errorf("expected X-Forwarded-Method=GET, got %q", got) + } + if got := r.Header.Get("X-Forwarded-Host"); got != "client.example" { + t.Errorf("expected X-Forwarded-Host=client.example, got %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + target, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse target: %v", err) + } + + engine := New() + engine.GET("/dynamic/path", ReverseProxy(ReverseProxyConfig{ + Target: target, + RequestHeaders: &HeaderOps{ + Add: map[string][]string{ + "X-Forwarded-Path": {"{path}"}, + "X-Forwarded-Method": {"{method}"}, + "X-Forwarded-Host": {"{host}"}, + }, + }, + })) + + proxy := httptest.NewServer(engine) + defer proxy.Close() + + req, _ := http.NewRequest(http.MethodGet, proxy.URL+"/dynamic/path", nil) + req.Host = "client.example" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} From 1243d2d37ad0fae1c255cbfda0efbe97ef7bce62 Mon Sep 17 00:00:00 2001 From: wjqserver <114663932+WJQSERVER@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:02:57 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20address=20PR=20review=20for=20replac?= =?UTF-8?q?er=20=E2=80=94=20nil=20check,=20EscapedPath,=20scheme=20reuse,?= =?UTF-8?q?=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add req.URL nil guard - use EscapedPath for {path} to avoid illegal header characters - reuse reverseProxyRequestScheme for {scheme} consistency - replace strings.NewReplacer with struct fields + strings.ReplaceAll --- reverseproxy.go | 62 +++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/reverseproxy.go b/reverseproxy.go index 5bb124f..1cf0078 100644 --- a/reverseproxy.go +++ b/reverseproxy.go @@ -264,32 +264,26 @@ func (ops *HeaderOps) Provision() error { } type reverseProxyReplacer struct { - req *http.Request - repl *strings.Replacer + method, host, path, query, scheme, uri, proto string } func newReverseProxyReplacer(req *http.Request) *reverseProxyReplacer { - r := &reverseProxyReplacer{req: req} - if req != nil { - uri := req.RequestURI - if uri == "" { - uri = req.URL.RequestURI() - } - scheme := "http" - if req.TLS != nil { - scheme = "https" - } - r.repl = strings.NewReplacer( - "{method}", req.Method, - "{host}", req.Host, - "{path}", req.URL.Path, - "{query}", req.URL.RawQuery, - "{scheme}", scheme, - "{uri}", uri, - "{proto}", req.Proto, - ) + if req == nil || req.URL == nil { + return &reverseProxyReplacer{} + } + uri := req.RequestURI + if uri == "" { + uri = req.URL.RequestURI() + } + return &reverseProxyReplacer{ + method: req.Method, + host: req.Host, + path: req.URL.EscapedPath(), + query: req.URL.RawQuery, + scheme: reverseProxyRequestScheme(req), + uri: uri, + proto: req.Proto, } - return r } func newReverseProxyReplacerFromHeader(hdr http.Header) *reverseProxyReplacer { @@ -300,10 +294,28 @@ func (r *reverseProxyReplacer) Replace(s string) string { if r == nil || s == "" { return s } - if r.repl == nil { - return s + if r.method != "" { + s = strings.ReplaceAll(s, "{method}", r.method) } - return r.repl.Replace(s) + if r.host != "" { + s = strings.ReplaceAll(s, "{host}", r.host) + } + if r.path != "" { + s = strings.ReplaceAll(s, "{path}", r.path) + } + if r.query != "" { + s = strings.ReplaceAll(s, "{query}", r.query) + } + if r.scheme != "" { + s = strings.ReplaceAll(s, "{scheme}", r.scheme) + } + if r.uri != "" { + s = strings.ReplaceAll(s, "{uri}", r.uri) + } + if r.proto != "" { + s = strings.ReplaceAll(s, "{proto}", r.proto) + } + return s } type reverseProxyHandler struct {