Repository: traefik/traefik (language: go)

Issue:
Keep URIs in server list
### Welcome!

- [X] Yes, I've searched similar issues on [GitHub](https://github.com/traefik/traefik/issues) and didn't find any.
- [X] Yes, I've searched similar issues on the [Traefik community forum](https://community.traefik.io) and didn't find any.

### What did you expect to see?

I have two backends that I'd like to load balance between, but both servers are authenticated using a different URI:

```yaml
services:
  my-backend:
    loadBalancer:
      servers:
        - url: https://my-first-backend.com/my-auth-token
        - url: https://my-second-backend.com/my-other-different-auth-token
```

Since Traefik strips the URI, this configuration is impossible. Also, since the URIs are different between these servers, the add-prefix middleware will not work, nor will health checks...

The end goal is to have a request come into traefik (`https://traefik:8080`) and then get routed to either backend, as long as it's healthy. In the future, more backends could be added and I'd like Traefik to load balance between them.

Is there any solution to this with Traefik or should I find a different proxy?

The feature request is to be able to keep the URI in the server field. If I could keep the URI, it seems like it would either solve my issue or get me closer to using this...


Discussion / hints:
Hello @rarecrumb , and thanks for your suggestion.

We are interested in this issue and the traction it will receive. We will leave the status as kind/proposal to give the community time to let us know if they would like this idea. We will reevaluate as people answer.

Fix patch:
diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml
index 18fc9bc84c..065d3ca16f 100644
--- a/docs/content/reference/dynamic-configuration/file.toml
+++ b/docs/content/reference/dynamic-configuration/file.toml
@@ -59,10 +59,12 @@
         [[http.services.Service02.loadBalancer.servers]]
           url = "foobar"
           weight = 42
+          preservePath = true
 
         [[http.services.Service02.loadBalancer.servers]]
           url = "foobar"
           weight = 42
+          preservePath = true
         [http.services.Service02.loadBalancer.healthCheck]
           scheme = "foobar"
           mode = "foobar"
diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml
index d0b0fc0fb7..08cb9c3851 100644
--- a/docs/content/reference/dynamic-configuration/file.yaml
+++ b/docs/content/reference/dynamic-configuration/file.yaml
@@ -66,8 +66,10 @@ http:
         servers:
           - url: foobar
             weight: 42
+            preservePath: true
           - url: foobar
             weight: 42
+            preservePath: true
         healthCheck:
           scheme: foobar
           mode: foobar
diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md
index 01de7ee69c..a2f24a8053 100644
--- a/docs/content/reference/dynamic-configuration/kv-ref.md
+++ b/docs/content/reference/dynamic-configuration/kv-ref.md
@@ -256,8 +256,10 @@ THIS FILE MUST NOT BE EDITED BY HAND
 | `traefik/http/services/Service02/loadBalancer/healthCheck/timeout` | `42s` |
 | `traefik/http/services/Service02/loadBalancer/passHostHeader` | `true` |
 | `traefik/http/services/Service02/loadBalancer/responseForwarding/flushInterval` | `42s` |
+| `traefik/http/services/Service02/loadBalancer/servers/0/preservePath` | `true` |
 | `traefik/http/services/Service02/loadBalancer/servers/0/url` | `foobar` |
 | `traefik/http/services/Service02/loadBalancer/servers/0/weight` | `42` |
+| `traefik/http/services/Service02/loadBalancer/servers/1/preservePath` | `true` |
 | `traefik/http/services/Service02/loadBalancer/servers/1/url` | `foobar` |
 | `traefik/http/services/Service02/loadBalancer/servers/1/weight` | `42` |
 | `traefik/http/services/Service02/loadBalancer/serversTransport` | `foobar` |
diff --git a/docs/content/routing/services/index.md b/docs/content/routing/services/index.md
index 8e4cc41f06..b58ac814a5 100644
--- a/docs/content/routing/services/index.md
+++ b/docs/content/routing/services/index.md
@@ -116,12 +116,8 @@ Each service has a load-balancer, even if there is only one server to forward tr
 #### Servers
 
 Servers declare a single instance of your program.
-The `url` option point to a specific instance.
 
-!!! info ""
-    Paths in the servers' `url` have no effect.
-    If you want the requests to be sent to a specific path on your servers,
-    configure your [`routers`](../routers/index.md) to use a corresponding [middleware](../../middlewares/overview.md) (e.g. the [AddPrefix](../../middlewares/http/addprefix.md) or [ReplacePath](../../middlewares/http/replacepath.md)) middlewares.
+The `url` option point to a specific instance.
 
 ??? example "A Service with One Server -- Using the [File Provider](../../providers/file.md)"
 
@@ -173,6 +169,34 @@ The `weight` option allows for weighted load balancing on the servers.
           weight = 1
     ```
 
+The `preservePath` option allows to preserve the URL path.
+
+!!! info "Health Check"
+
+    When a [health check](#health-check) is configured for the server, the path is not preserved.
+
+??? example "A Service with One Server and PreservePath -- Using the [File Provider](../../providers/file.md)"
+
+    ```yaml tab="YAML"
+    ## Dynamic configuration
+    http:
+      services:
+        my-service:
+          loadBalancer:
+            servers:
+              - url: "http://private-ip-server-1/base"
+                preservePath: true
+    ```
+
+    ```toml tab="TOML"
+    ## Dynamic configuration
+    [http.services]
+      [http.services.my-service.loadBalancer]
+        [[http.services.my-service.loadBalancer.servers]]
+          url = "http://private-ip-server-1/base"
+          preservePath = true
+    ```
+
 #### Load-balancing
 
 For now, only round robin load balancing is supported:
diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go
index d347df81e5..e29bdd30a8 100644
--- a/pkg/config/dynamic/http_config.go
+++ b/pkg/config/dynamic/http_config.go
@@ -244,10 +244,11 @@ func (r *ResponseForwarding) SetDefaults() {
 
 // Server holds the server configuration.
 type Server struct {
-	URL    string `json:"url,omitempty" toml:"url,omitempty" yaml:"url,omitempty" label:"-"`
-	Weight *int   `json:"weight,omitempty" toml:"weight,omitempty" yaml:"weight,omitempty" label:"weight"`
-	Scheme string `json:"-" toml:"-" yaml:"-" file:"-"`
-	Port   string `json:"-" toml:"-" yaml:"-" file:"-"`
+	URL          string `json:"url,omitempty" toml:"url,omitempty" yaml:"url,omitempty" label:"-"`
+	Weight       *int   `json:"weight,omitempty" toml:"weight,omitempty" yaml:"weight,omitempty" label:"weight" export:"true"`
+	PreservePath bool   `json:"preservePath,omitempty" toml:"preservePath,omitempty" yaml:"preservePath,omitempty" label:"-" export:"true"`
+	Scheme       string `json:"-" toml:"-" yaml:"-" file:"-"`
+	Port         string `json:"-" toml:"-" yaml:"-" file:"-"`
 }
 
 // SetDefaults Default values for a Server.
diff --git a/pkg/proxy/fast/builder.go b/pkg/proxy/fast/builder.go
index f330d6756d..c53363dad9 100644
--- a/pkg/proxy/fast/builder.go
+++ b/pkg/proxy/fast/builder.go
@@ -68,7 +68,7 @@ func (r *ProxyBuilder) Update(newConfigs map[string]*dynamic.ServersTransport) {
 }
 
 // Build builds a new ReverseProxy with the given configuration.
-func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader bool) (http.Handler, error) {
+func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader, preservePath bool) (http.Handler, error) {
 	proxyURL, err := r.proxy(&http.Request{URL: targetURL})
 	if err != nil {
 		return nil, fmt.Errorf("getting proxy: %w", err)
@@ -90,7 +90,7 @@ func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, passHostHeader
 	}
 
 	pool := r.getPool(cfgName, cfg, tlsConfig, targetURL, proxyURL)
-	return NewReverseProxy(targetURL, proxyURL, r.debug, passHostHeader, responseHeaderTimeout, pool)
+	return NewReverseProxy(targetURL, proxyURL, r.debug, passHostHeader, preservePath, responseHeaderTimeout, pool)
 }
 
 func (r *ProxyBuilder) getPool(cfgName string, config *dynamic.ServersTransport, tlsConfig *tls.Config, targetURL *url.URL, proxyURL *url.URL) *connPool {
diff --git a/pkg/proxy/fast/proxy.go b/pkg/proxy/fast/proxy.go
index e61a32ad85..388a4d73ce 100644
--- a/pkg/proxy/fast/proxy.go
+++ b/pkg/proxy/fast/proxy.go
@@ -121,11 +121,12 @@ type ReverseProxy struct {
 
 	targetURL             *url.URL
 	passHostHeader        bool
+	preservePath          bool
 	responseHeaderTimeout time.Duration
 }
 
 // NewReverseProxy creates a new ReverseProxy.
-func NewReverseProxy(targetURL *url.URL, proxyURL *url.URL, debug, passHostHeader bool, responseHeaderTimeout time.Duration, connPool *connPool) (*ReverseProxy, error) {
+func NewReverseProxy(targetURL, proxyURL *url.URL, debug, passHostHeader, preservePath bool, responseHeaderTimeout time.Duration, connPool *connPool) (*ReverseProxy, error) {
 	var proxyAuth string
 	if proxyURL != nil && proxyURL.User != nil && targetURL.Scheme == "http" {
 		username := proxyURL.User.Username()
@@ -136,6 +137,7 @@ func NewReverseProxy(targetURL *url.URL, proxyURL *url.URL, debug, passHostHeade
 	return &ReverseProxy{
 		debug:                 debug,
 		passHostHeader:        passHostHeader,
+		preservePath:          preservePath,
 		targetURL:             targetURL,
 		proxyAuth:             proxyAuth,
 		connPool:              connPool,
@@ -207,6 +209,11 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 
 	u2.Path = u.Path
 	u2.RawPath = u.RawPath
+
+	if p.preservePath {
+		u2.Path, u2.RawPath = proxyhttputil.JoinURLPath(p.targetURL, u)
+	}
+
 	u2.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&")
 
 	outReq.SetHost(u2.Host)
diff --git a/pkg/proxy/httputil/builder.go b/pkg/proxy/httputil/builder.go
index ee88bd0c39..64360517ae 100644
--- a/pkg/proxy/httputil/builder.go
+++ b/pkg/proxy/httputil/builder.go
@@ -38,7 +38,7 @@ func NewProxyBuilder(transportManager TransportManager, semConvMetricsRegistry *
 func (r *ProxyBuilder) Update(_ map[string]*dynamic.ServersTransport) {}
 
 // Build builds a new httputil.ReverseProxy with the given configuration.
-func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error) {
+func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error) {
 	roundTripper, err := r.transportManager.GetRoundTripper(cfgName)
 	if err != nil {
 		return nil, fmt.Errorf("getting RoundTripper: %w", err)
@@ -50,5 +50,5 @@ func (r *ProxyBuilder) Build(cfgName string, targetURL *url.URL, shouldObserve,
 		roundTripper = newObservabilityRoundTripper(r.semConvMetricsRegistry, roundTripper)
 	}
 
-	return buildSingleHostProxy(targetURL, passHostHeader, flushInterval, roundTripper, r.bufferPool), nil
+	return buildSingleHostProxy(targetURL, passHostHeader, preservePath, flushInterval, roundTripper, r.bufferPool), nil
 }
diff --git a/pkg/proxy/httputil/proxy.go b/pkg/proxy/httputil/proxy.go
index e0401ea1f3..8ed35c23ac 100644
--- a/pkg/proxy/httputil/proxy.go
+++ b/pkg/proxy/httputil/proxy.go
@@ -15,15 +15,17 @@ import (
 	"golang.org/x/net/http/httpguts"
 )
 
-// StatusClientClosedRequest non-standard HTTP status code for client disconnection.
-const StatusClientClosedRequest = 499
+const (
+	// StatusClientClosedRequest non-standard HTTP status code for client disconnection.
+	StatusClientClosedRequest = 499
 
-// StatusClientClosedRequestText non-standard HTTP status for client disconnection.
-const StatusClientClosedRequestText = "Client Closed Request"
+	// StatusClientClosedRequestText non-standard HTTP status for client disconnection.
+	StatusClientClosedRequestText = "Client Closed Request"
+)
 
-func buildSingleHostProxy(target *url.URL, passHostHeader bool, flushInterval time.Duration, roundTripper http.RoundTripper, bufferPool httputil.BufferPool) http.Handler {
+func buildSingleHostProxy(target *url.URL, passHostHeader bool, preservePath bool, flushInterval time.Duration, roundTripper http.RoundTripper, bufferPool httputil.BufferPool) http.Handler {
 	return &httputil.ReverseProxy{
-		Director:      directorBuilder(target, passHostHeader),
+		Director:      directorBuilder(target, passHostHeader, preservePath),
 		Transport:     roundTripper,
 		FlushInterval: flushInterval,
 		BufferPool:    bufferPool,
@@ -31,7 +33,7 @@ func buildSingleHostProxy(target *url.URL, passHostHeader bool, flushInterval ti
 	}
 }
 
-func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Request) {
+func directorBuilder(target *url.URL, passHostHeader bool, preservePath bool) func(req *http.Request) {
 	return func(outReq *http.Request) {
 		outReq.URL.Scheme = target.Scheme
 		outReq.URL.Host = target.Host
@@ -46,6 +48,11 @@ func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Reques
 
 		outReq.URL.Path = u.Path
 		outReq.URL.RawPath = u.RawPath
+
+		if preservePath {
+			outReq.URL.Path, outReq.URL.RawPath = JoinURLPath(target, u)
+		}
+
 		// If a plugin/middleware adds semicolons in query params, they should be urlEncoded.
 		outReq.URL.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&")
 		outReq.RequestURI = "" // Outgoing request should not have RequestURI
@@ -54,7 +61,7 @@ func directorBuilder(target *url.URL, passHostHeader bool) func(req *http.Reques
 		outReq.ProtoMajor = 1
 		outReq.ProtoMinor = 1
 
-		// Do not pass client Host header unless optsetter PassHostHeader is set.
+		// Do not pass client Host header unless option PassHostHeader is set.
 		if !passHostHeader {
 			outReq.Host = outReq.URL.Host
 		}
@@ -106,6 +113,13 @@ func ErrorHandler(w http.ResponseWriter, req *http.Request, err error) {
 	}
 }
 
+func statusText(statusCode int) string {
+	if statusCode == StatusClientClosedRequest {
+		return StatusClientClosedRequestText
+	}
+	return http.StatusText(statusCode)
+}
+
 // ComputeStatusCode computes the HTTP status code according to the given error.
 func ComputeStatusCode(err error) int {
 	switch {
@@ -127,9 +141,38 @@ func ComputeStatusCode(err error) int {
 	return http.StatusInternalServerError
 }
 
-func statusText(statusCode int) string {
-	if statusCode == StatusClientClosedRequest {
-		return StatusClientClosedRequestText
+// JoinURLPath computes the joined path and raw path of the given URLs.
+// From https://github.com/golang/go/blob/b521ebb55a9b26c8824b219376c7f91f7cda6ec2/src/net/http/httputil/reverseproxy.go#L221
+func JoinURLPath(a, b *url.URL) (path, rawpath string) {
+	if a.RawPath == "" && b.RawPath == "" {
+		return singleJoiningSlash(a.Path, b.Path), ""
 	}
-	return http.StatusText(statusCode)
+
+	// Same as singleJoiningSlash, but uses EscapedPath to determine
+	// whether a slash should be added
+	apath := a.EscapedPath()
+	bpath := b.EscapedPath()
+
+	aslash := strings.HasSuffix(apath, "/")
+	bslash := strings.HasPrefix(bpath, "/")
+
+	switch {
+	case aslash && bslash:
+		return a.Path + b.Path[1:], apath + bpath[1:]
+	case !aslash && !bslash:
+		return a.Path + "/" + b.Path, apath + "/" + bpath
+	}
+	return a.Path + b.Path, apath + bpath
+}
+
+func singleJoiningSlash(a, b string) string {
+	aslash := strings.HasSuffix(a, "/")
+	bslash := strings.HasPrefix(b, "/")
+	switch {
+	case aslash && bslash:
+		return a + b[1:]
+	case !aslash && !bslash:
+		return a + "/" + b
+	}
+	return a + b
 }
diff --git a/pkg/proxy/smart_builder.go b/pkg/proxy/smart_builder.go
index 1abadcca74..08b247c53f 100644
--- a/pkg/proxy/smart_builder.go
+++ b/pkg/proxy/smart_builder.go
@@ -45,7 +45,7 @@ func (b *SmartBuilder) Update(newConfigs map[string]*dynamic.ServersTransport) {
 }
 
 // Build builds an HTTP proxy for the given URL using the ServersTransport with the given name.
-func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error) {
+func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error) {
 	serversTransport, err := b.transportManager.Get(configName)
 	if err != nil {
 		return nil, fmt.Errorf("getting ServersTransport: %w", err)
@@ -55,7 +55,7 @@ func (b *SmartBuilder) Build(configName string, targetURL *url.URL, shouldObserv
 	// For the https scheme we cannot guess if the backend communication will use HTTP2,
 	// thus we check if HTTP/2 is disabled to use the fast proxy implementation when this is possible.
 	if targetURL.Scheme == "h2c" || (targetURL.Scheme == "https" && !serversTransport.DisableHTTP2) {
-		return b.proxyBuilder.Build(configName, targetURL, shouldObserve, passHostHeader, flushInterval)
+		return b.proxyBuilder.Build(configName, targetURL, shouldObserve, passHostHeader, preservePath, flushInterval)
 	}
-	return b.fastProxyBuilder.Build(configName, targetURL, passHostHeader)
+	return b.fastProxyBuilder.Build(configName, targetURL, passHostHeader, preservePath)
 }
diff --git a/pkg/server/service/service.go b/pkg/server/service/service.go
index e37ecdf09e..349c031799 100644
--- a/pkg/server/service/service.go
+++ b/pkg/server/service/service.go
@@ -42,7 +42,7 @@ const (
 
 // ProxyBuilder builds reverse proxy handlers.
 type ProxyBuilder interface {
-	Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader bool, flushInterval time.Duration) (http.Handler, error)
+	Build(cfgName string, targetURL *url.URL, shouldObserve, passHostHeader, preservePath bool, flushInterval time.Duration) (http.Handler, error)
 	Update(configs map[string]*dynamic.ServersTransport)
 }
 
@@ -338,7 +338,7 @@ func (m *Manager) getLoadBalancerServiceHandler(ctx context.Context, serviceName
 		qualifiedSvcName := provider.GetQualifiedName(ctx, serviceName)
 
 		shouldObserve := m.observabilityMgr.ShouldAddTracing(qualifiedSvcName) || m.observabilityMgr.ShouldAddMetrics(qualifiedSvcName)
-		proxy, err := m.proxyBuilder.Build(service.ServersTransport, target, shouldObserve, passHostHeader, flushInterval)
+		proxy, err := m.proxyBuilder.Build(service.ServersTransport, target, shouldObserve, passHostHeader, server.PreservePath, flushInterval)
 		if err != nil {
 			return nil, fmt.Errorf("error building proxy for server URL %s: %w", server.URL, err)
 		}


Test patch:
diff --git a/pkg/proxy/fast/proxy_test.go b/pkg/proxy/fast/proxy_test.go
index ee75ae0db7..f4593d9ce2 100644
--- a/pkg/proxy/fast/proxy_test.go
+++ b/pkg/proxy/fast/proxy_test.go
@@ -230,7 +230,7 @@ func TestProxyFromEnvironment(t *testing.T) {
 				return u, nil
 			}
 
-			reverseProxy, err := builder.Build("foo", testhelpers.MustParseURL(backendURL), false)
+			reverseProxy, err := builder.Build("foo", testhelpers.MustParseURL(backendURL), false, false)
 			require.NoError(t, err)
 
 			reverseProxyServer := httptest.NewServer(reverseProxy)
@@ -252,6 +252,32 @@ func TestProxyFromEnvironment(t *testing.T) {
 	}
 }
 
+func TestPreservePath(t *testing.T) {
+	var callCount int
+	server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		callCount++
+		assert.Equal(t, "/base/foo/bar", req.URL.Path)
+		assert.Equal(t, "/base/foo%2Fbar", req.URL.RawPath)
+	}))
+	t.Cleanup(server.Close)
+
+	builder := NewProxyBuilder(&transportManagerMock{}, static.FastProxyConfig{})
+
+	serverURL, err := url.JoinPath(server.URL, "base")
+	require.NoError(t, err)
+
+	proxyHandler, err := builder.Build("", testhelpers.MustParseURL(serverURL), true, true)
+	require.NoError(t, err)
+
+	req := httptest.NewRequest(http.MethodGet, "/foo%2Fbar", http.NoBody)
+	res := httptest.NewRecorder()
+
+	proxyHandler.ServeHTTP(res, req)
+
+	assert.Equal(t, 1, callCount)
+	assert.Equal(t, http.StatusOK, res.Code)
+}
+
 func newCertificate(t *testing.T, domain string) *tls.Certificate {
 	t.Helper()
 
diff --git a/pkg/proxy/fast/proxy_websocket_test.go b/pkg/proxy/fast/proxy_websocket_test.go
index ff2e273e9d..8297ac485f 100644
--- a/pkg/proxy/fast/proxy_websocket_test.go
+++ b/pkg/proxy/fast/proxy_websocket_test.go
@@ -362,7 +362,7 @@ func TestWebSocketRequestWithHeadersInResponseWriter(t *testing.T) {
 
 	u := parseURI(t, srv.URL)
 
-	f, err := NewReverseProxy(u, nil, true, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
+	f, err := NewReverseProxy(u, nil, true, false, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
 		return net.Dial("tcp", u.Host)
 	}))
 	require.NoError(t, err)
@@ -434,7 +434,7 @@ func TestWebSocketUpgradeFailed(t *testing.T) {
 	defer srv.Close()
 
 	u := parseURI(t, srv.URL)
-	f, err := NewReverseProxy(u, nil, true, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
+	f, err := NewReverseProxy(u, nil, true, false, false, 0, newConnPool(1, 0, func() (net.Conn, error) {
 		return net.Dial("tcp", u.Host)
 	}))
 	require.NoError(t, err)
@@ -676,7 +676,7 @@ func createProxyWithForwarder(t *testing.T, uri string, pool *connPool) *httptes
 	t.Helper()
 
 	u := parseURI(t, uri)
-	proxy, err := NewReverseProxy(u, nil, false, true, 0, pool)
+	proxy, err := NewReverseProxy(u, nil, false, true, false, 0, pool)
 	require.NoError(t, err)
 
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
diff --git a/pkg/proxy/httputil/builder_test.go b/pkg/proxy/httputil/builder_test.go
index 8033635d20..f7ff939024 100644
--- a/pkg/proxy/httputil/builder_test.go
+++ b/pkg/proxy/httputil/builder_test.go
@@ -23,7 +23,7 @@ func TestEscapedPath(t *testing.T) {
 		roundTrippers: map[string]http.RoundTripper{"default": &http.Transport{}},
 	}
 
-	p, err := NewProxyBuilder(transportManager, nil).Build("default", testhelpers.MustParseURL(srv.URL), false, true, 0)
+	p, err := NewProxyBuilder(transportManager, nil).Build("default", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
 	require.NoError(t, err)
 
 	proxy := httptest.NewServer(http.HandlerFunc(p.ServeHTTP))
diff --git a/pkg/proxy/httputil/proxy_test.go b/pkg/proxy/httputil/proxy_test.go
new file mode 100644
index 0000000000..3d970fc534
--- /dev/null
+++ b/pkg/proxy/httputil/proxy_test.go
@@ -0,0 +1,102 @@
+package httputil
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/traefik/traefik/v3/pkg/testhelpers"
+)
+
+func Test_directorBuilder(t *testing.T) {
+	tests := []struct {
+		name            string
+		target          *url.URL
+		passHostHeader  bool
+		preservePath    bool
+		incomingURL     string
+		expectedScheme  string
+		expectedHost    string
+		expectedPath    string
+		expectedRawPath string
+		expectedQuery   string
+	}{
+		{
+			name:           "Basic proxy",
+			target:         testhelpers.MustParseURL("http://example.com"),
+			passHostHeader: false,
+			preservePath:   false,
+			incomingURL:    "http://localhost/test?param=value",
+			expectedScheme: "http",
+			expectedHost:   "example.com",
+			expectedPath:   "/test",
+			expectedQuery:  "param=value",
+		},
+		{
+			name:           "HTTPS target",
+			target:         testhelpers.MustParseURL("https://secure.example.com"),
+			passHostHeader: false,
+			preservePath:   false,
+			incomingURL:    "http://localhost/secure",
+			expectedScheme: "https",
+			expectedHost:   "secure.example.com",
+			expectedPath:   "/secure",
+		},
+		{
+			name:           "PassHostHeader",
+			target:         testhelpers.MustParseURL("http://example.com"),
+			passHostHeader: true,
+			preservePath:   false,
+			incomingURL:    "http://original.host/test",
+			expectedScheme: "http",
+			expectedHost:   "original.host",
+			expectedPath:   "/test",
+		},
+		{
+			name:            "Preserve path",
+			target:          testhelpers.MustParseURL("http://example.com/base"),
+			passHostHeader:  false,
+			preservePath:    true,
+			incomingURL:     "http://localhost/foo%2Fbar",
+			expectedScheme:  "http",
+			expectedHost:    "example.com",
+			expectedPath:    "/base/foo/bar",
+			expectedRawPath: "/base/foo%2Fbar",
+		},
+		{
+			name:           "Handle semicolons in query",
+			target:         testhelpers.MustParseURL("http://example.com"),
+			passHostHeader: false,
+			preservePath:   false,
+			incomingURL:    "http://localhost/test?param1=value1;param2=value2",
+			expectedScheme: "http",
+			expectedHost:   "example.com",
+			expectedPath:   "/test",
+			expectedQuery:  "param1=value1&param2=value2",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+
+			director := directorBuilder(test.target, test.passHostHeader, test.preservePath)
+
+			req := httptest.NewRequest(http.MethodGet, test.incomingURL, http.NoBody)
+			director(req)
+
+			assert.Equal(t, test.expectedScheme, req.URL.Scheme)
+			assert.Equal(t, test.expectedHost, req.Host)
+			assert.Equal(t, test.expectedPath, req.URL.Path)
+			assert.Equal(t, test.expectedRawPath, req.URL.RawPath)
+			assert.Equal(t, test.expectedQuery, req.URL.RawQuery)
+			assert.Empty(t, req.RequestURI)
+			assert.Equal(t, "HTTP/1.1", req.Proto)
+			assert.Equal(t, 1, req.ProtoMajor)
+			assert.Equal(t, 1, req.ProtoMinor)
+			assert.False(t, !test.passHostHeader && req.Host != req.URL.Host)
+		})
+	}
+}
diff --git a/pkg/proxy/httputil/proxy_websocket_test.go b/pkg/proxy/httputil/proxy_websocket_test.go
index dc6fa8c82c..5472bcce09 100644
--- a/pkg/proxy/httputil/proxy_websocket_test.go
+++ b/pkg/proxy/httputil/proxy_websocket_test.go
@@ -298,9 +298,8 @@ func TestWebSocketRequestWithHeadersInResponseWriter(t *testing.T) {
 		},
 	}
 
-	p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, 0)
+	p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
 	require.NoError(t, err)
-
 	proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		req.URL = testhelpers.MustParseURL(srv.URL)
 		w.Header().Set("HEADER-KEY", "HEADER-VALUE")
@@ -355,9 +354,8 @@ func TestWebSocketUpgradeFailed(t *testing.T) {
 		},
 	}
 
-	p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, 0)
+	p, err := NewProxyBuilder(transportManager, nil).Build("default@internal", testhelpers.MustParseURL(srv.URL), false, true, false, 0)
 	require.NoError(t, err)
-
 	proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		path := req.URL.Path // keep the original path
 
@@ -588,7 +586,7 @@ func createProxyWithForwarder(t *testing.T, uri string, transport http.RoundTrip
 		roundTrippers: map[string]http.RoundTripper{"fwd": transport},
 	}
 
-	p, err := NewProxyBuilder(transportManager, nil).Build("fwd", u, false, true, 0)
+	p, err := NewProxyBuilder(transportManager, nil).Build("fwd", u, false, true, false, 0)
 	require.NoError(t, err)
 
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
diff --git a/pkg/proxy/smart_builder_test.go b/pkg/proxy/smart_builder_test.go
index d1c29ddd8c..c03bd19f37 100644
--- a/pkg/proxy/smart_builder_test.go
+++ b/pkg/proxy/smart_builder_test.go
@@ -101,7 +101,7 @@ func TestSmartBuilder_Build(t *testing.T) {
 			httpProxyBuilder := httputil.NewProxyBuilder(transportManager, nil)
 			proxyBuilder := NewSmartBuilder(transportManager, httpProxyBuilder, test.fastProxyConfig)
 
-			proxyHandler, err := proxyBuilder.Build("test", targetURL, false, false, time.Second)
+			proxyHandler, err := proxyBuilder.Build("test", targetURL, false, false, false, time.Second)
 			require.NoError(t, err)
 
 			rw := httptest.NewRecorder()
diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go
index b545bd2daa..70808e9c62 100644
--- a/pkg/server/router/router_test.go
+++ b/pkg/server/router/router_test.go
@@ -897,7 +897,7 @@ func BenchmarkService(b *testing.B) {
 
 type proxyBuilderMock struct{}
 
-func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) {
+func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _, _ bool, _ time.Duration) (http.Handler, error) {
 	return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {}), nil
 }
 
diff --git a/pkg/server/routerfactory_test.go b/pkg/server/routerfactory_test.go
index 6c0861d380..2b3d6fe1f5 100644
--- a/pkg/server/routerfactory_test.go
+++ b/pkg/server/routerfactory_test.go
@@ -254,7 +254,7 @@ func TestInternalServices(t *testing.T) {
 
 type proxyBuilderMock struct{}
 
-func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) {
+func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _, _ bool, _ time.Duration) (http.Handler, error) {
 	return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {}), nil
 }