diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index f348dd389..8f73e9d59 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -80,7 +80,7 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } rp := &httputil.ReverseProxy{ - Rewrite: p.rewriteFunc(result.url, result.passHostHeader), + Rewrite: p.rewriteFunc(result.url, result.matchedPath, result.passHostHeader), Transport: p.transport, ErrorHandler: proxyErrorHandler, } @@ -92,8 +92,18 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // forwarding headers and stripping proxy authentication credentials. // When passHostHeader is true, the original client Host header is preserved // instead of being rewritten to the backend's address. -func (p *ReverseProxy) rewriteFunc(target *url.URL, passHostHeader bool) func(r *httputil.ProxyRequest) { +func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool) func(r *httputil.ProxyRequest) { return func(r *httputil.ProxyRequest) { + // Strip the matched path prefix from the incoming request path before + // SetURL joins it with the target's base path, avoiding path duplication. + if matchedPath != "" && matchedPath != "/" { + r.Out.URL.Path = strings.TrimPrefix(r.Out.URL.Path, matchedPath) + if r.Out.URL.Path == "" { + r.Out.URL.Path = "/" + } + r.Out.URL.RawPath = "" + } + r.SetURL(target) if passHostHeader { r.Out.Host = r.In.Host diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 8a2ad209c..06a2c6a26 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -20,7 +20,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} t.Run("rewrites host to backend by default", func(t *testing.T) { - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") rewrite(pr) @@ -29,7 +29,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { }) t.Run("preserves original host when passHostHeader is true", func(t *testing.T) { - rewrite := p.rewriteFunc(target, true) + rewrite := p.rewriteFunc(target, "", true) pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") rewrite(pr) @@ -44,7 +44,7 @@ func TestRewriteFunc_HostRewriting(t *testing.T) { func TestRewriteFunc_XForwardedForStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) t.Run("sets X-Forwarded-For from direct connection IP", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") @@ -81,7 +81,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("sets X-Forwarded-Host to original host", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://myapp.example.com:8443/path", "1.2.3.4:5000") rewrite(pr) @@ -91,7 +91,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("sets X-Forwarded-Port from explicit host port", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com:8443/path", "1.2.3.4:5000") rewrite(pr) @@ -101,7 +101,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("defaults X-Forwarded-Port to 443 for https", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -112,7 +112,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("defaults X-Forwarded-Port to 80 for http", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -122,7 +122,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("auto detects https from TLS", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -133,7 +133,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("auto detects http without TLS", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") rewrite(pr) @@ -143,7 +143,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("forced proto overrides TLS detection", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "https"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") // No TLS, but forced to https @@ -154,7 +154,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { t.Run("forced http proto", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "http"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") pr.In.TLS = &tls.ConnectionState{} @@ -167,7 +167,7 @@ func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { func TestRewriteFunc_SessionCookieStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) t.Run("strips nb_session cookie", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") @@ -212,7 +212,7 @@ func TestRewriteFunc_SessionCookieStripping(t *testing.T) { func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { target, _ := url.Parse("http://backend.internal:8080") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) t.Run("strips session_token query parameter", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/callback?session_token=secret123&other=keep", "1.2.3.4:5000") @@ -236,11 +236,11 @@ func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { } func TestRewriteFunc_URLRewriting(t *testing.T) { - target, _ := url.Parse("http://backend.internal:8080/app") p := &ReverseProxy{forwardedProto: "auto"} - rewrite := p.rewriteFunc(target, false) t.Run("rewrites URL to target with path prefix", func(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080/app") + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/somepath", "1.2.3.4:5000") rewrite(pr) @@ -250,6 +250,30 @@ func TestRewriteFunc_URLRewriting(t *testing.T) { assert.Equal(t, "/app/somepath", pr.Out.URL.Path, "SetURL should join the target base path with the request path") }) + + t.Run("strips matched path prefix to avoid duplication", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false) + pr := newProxyRequest(t, "http://example.com/app", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.URL.Scheme) + assert.Equal(t, "backend.example.org:443", pr.Out.URL.Host) + assert.Equal(t, "/app/", pr.Out.URL.Path, + "matched path prefix should be stripped before joining with target path") + }) + + t.Run("strips matched prefix and preserves subpath", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false) + pr := newProxyRequest(t, "http://example.com/app/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/app/article/123", pr.Out.URL.Path, + "subpath after matched prefix should be preserved") + }) } func TestExtractClientIP(t *testing.T) { @@ -300,7 +324,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("appends to X-Forwarded-For", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -312,7 +336,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Real-IP", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -325,7 +349,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("resolves X-Real-IP from XFF when not set by upstream", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2") @@ -338,7 +362,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Host", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://proxy.internal/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Host", "original.example.com") @@ -350,7 +374,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Proto", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Proto", "https") @@ -362,7 +386,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("preserves upstream X-Forwarded-Port", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-Port", "8443") @@ -374,7 +398,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("falls back to local proto when upstream does not set it", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "https", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") @@ -386,7 +410,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("sets X-Forwarded-Host from request when upstream does not set it", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") @@ -397,7 +421,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("untrusted RemoteAddr strips headers even with trusted list", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") @@ -422,7 +446,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("empty trusted list behaves as untrusted", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: nil} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") @@ -435,7 +459,7 @@ func TestRewriteFunc_TrustedProxy(t *testing.T) { t.Run("XFF starts fresh when trusted proxy has no upstream XFF", func(t *testing.T) { p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} - rewrite := p.rewriteFunc(target, false) + rewrite := p.rewriteFunc(target, "", false) pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go index 4e55c8dc4..cff2963bd 100644 --- a/proxy/internal/proxy/servicemapping.go +++ b/proxy/internal/proxy/servicemapping.go @@ -20,6 +20,7 @@ type Mapping struct { type targetResult struct { url *url.URL + matchedPath string serviceID string accountID types.AccountID passHostHeader bool @@ -56,6 +57,7 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, target) return targetResult{ url: target, + matchedPath: path, serviceID: m.ID, accountID: m.AccountID, passHostHeader: m.PassHostHeader,