feat: add global option to disable X-Forwarded-For appending

This commit is contained in:
Landry Benguigui
2025-12-19 11:18:04 +01:00
committed by GitHub
parent 704f69272c
commit 78e2dab155
17 changed files with 514 additions and 64 deletions
@@ -83,6 +83,7 @@ THIS FILE MUST NOT BE EDITED BY HAND
| <a id="opt-entrypoints-name-asdefault" href="#opt-entrypoints-name-asdefault" title="#opt-entrypoints-name-asdefault">entrypoints._name_.asdefault</a> | Adds this EntryPoint to the list of default EntryPoints to be used on routers that don't have any Entrypoint defined. | false | | <a id="opt-entrypoints-name-asdefault" href="#opt-entrypoints-name-asdefault" title="#opt-entrypoints-name-asdefault">entrypoints._name_.asdefault</a> | Adds this EntryPoint to the list of default EntryPoints to be used on routers that don't have any Entrypoint defined. | false |
| <a id="opt-entrypoints-name-forwardedheaders-connection" href="#opt-entrypoints-name-forwardedheaders-connection" title="#opt-entrypoints-name-forwardedheaders-connection">entrypoints._name_.forwardedheaders.connection</a> | List of Connection headers that are allowed to pass through the middleware chain before being removed. | | | <a id="opt-entrypoints-name-forwardedheaders-connection" href="#opt-entrypoints-name-forwardedheaders-connection" title="#opt-entrypoints-name-forwardedheaders-connection">entrypoints._name_.forwardedheaders.connection</a> | List of Connection headers that are allowed to pass through the middleware chain before being removed. | |
| <a id="opt-entrypoints-name-forwardedheaders-insecure" href="#opt-entrypoints-name-forwardedheaders-insecure" title="#opt-entrypoints-name-forwardedheaders-insecure">entrypoints._name_.forwardedheaders.insecure</a> | Trust all forwarded headers. | false | | <a id="opt-entrypoints-name-forwardedheaders-insecure" href="#opt-entrypoints-name-forwardedheaders-insecure" title="#opt-entrypoints-name-forwardedheaders-insecure">entrypoints._name_.forwardedheaders.insecure</a> | Trust all forwarded headers. | false |
| <a id="opt-entrypoints-name-forwardedheaders-notappendxforwardedfor" href="#opt-entrypoints-name-forwardedheaders-notappendxforwardedfor" title="#opt-entrypoints-name-forwardedheaders-notappendxforwardedfor">entrypoints._name_.forwardedheaders.notappendxforwardedfor</a> | Disable appending RemoteAddr to X-Forwarded-For header. Defaults to false (appending is enabled). | false |
| <a id="opt-entrypoints-name-forwardedheaders-trustedips" href="#opt-entrypoints-name-forwardedheaders-trustedips" title="#opt-entrypoints-name-forwardedheaders-trustedips">entrypoints._name_.forwardedheaders.trustedips</a> | Trust only forwarded headers from selected IPs. | | | <a id="opt-entrypoints-name-forwardedheaders-trustedips" href="#opt-entrypoints-name-forwardedheaders-trustedips" title="#opt-entrypoints-name-forwardedheaders-trustedips">entrypoints._name_.forwardedheaders.trustedips</a> | Trust only forwarded headers from selected IPs. | |
| <a id="opt-entrypoints-name-http" href="#opt-entrypoints-name-http" title="#opt-entrypoints-name-http">entrypoints._name_.http</a> | HTTP configuration. | | | <a id="opt-entrypoints-name-http" href="#opt-entrypoints-name-http" title="#opt-entrypoints-name-http">entrypoints._name_.http</a> | HTTP configuration. | |
| <a id="opt-entrypoints-name-http-encodequerysemicolons" href="#opt-entrypoints-name-http-encodequerysemicolons" title="#opt-entrypoints-name-http-encodequerysemicolons">entrypoints._name_.http.encodequerysemicolons</a> | Defines whether request query semicolons should be URLEncoded. | false | | <a id="opt-entrypoints-name-http-encodequerysemicolons" href="#opt-entrypoints-name-http-encodequerysemicolons" title="#opt-entrypoints-name-http-encodequerysemicolons">entrypoints._name_.http.encodequerysemicolons</a> | Defines whether request query semicolons should be URLEncoded. | false |
@@ -141,6 +142,7 @@ THIS FILE MUST NOT BE EDITED BY HAND
| <a id="opt-experimental-plugins-name-settings-useunsafe" href="#opt-experimental-plugins-name-settings-useunsafe" title="#opt-experimental-plugins-name-settings-useunsafe">experimental.plugins._name_.settings.useunsafe</a> | Allow the plugin to use unsafe and syscall packages. | false | | <a id="opt-experimental-plugins-name-settings-useunsafe" href="#opt-experimental-plugins-name-settings-useunsafe" title="#opt-experimental-plugins-name-settings-useunsafe">experimental.plugins._name_.settings.useunsafe</a> | Allow the plugin to use unsafe and syscall packages. | false |
| <a id="opt-experimental-plugins-name-version" href="#opt-experimental-plugins-name-version" title="#opt-experimental-plugins-name-version">experimental.plugins._name_.version</a> | plugin's version. | | | <a id="opt-experimental-plugins-name-version" href="#opt-experimental-plugins-name-version" title="#opt-experimental-plugins-name-version">experimental.plugins._name_.version</a> | plugin's version. | |
| <a id="opt-global-checknewversion" href="#opt-global-checknewversion" title="#opt-global-checknewversion">global.checknewversion</a> | Periodically check if a new version has been released. | true | | <a id="opt-global-checknewversion" href="#opt-global-checknewversion" title="#opt-global-checknewversion">global.checknewversion</a> | Periodically check if a new version has been released. | true |
| <a id="opt-global-notappendxforwardedfor" href="#opt-global-notappendxforwardedfor" title="#opt-global-notappendxforwardedfor">global.notappendxforwardedfor</a> | Disable appending RemoteAddr to X-Forwarded-For header. Defaults to false (appending is enabled). | false |
| <a id="opt-global-sendanonymoususage" href="#opt-global-sendanonymoususage" title="#opt-global-sendanonymoususage">global.sendanonymoususage</a> | Periodically send anonymous usage statistics. If the option is not specified, it will be disabled by default. | false | | <a id="opt-global-sendanonymoususage" href="#opt-global-sendanonymoususage" title="#opt-global-sendanonymoususage">global.sendanonymoususage</a> | Periodically send anonymous usage statistics. If the option is not specified, it will be disabled by default. | false |
| <a id="opt-hostresolver" href="#opt-hostresolver" title="#opt-hostresolver">hostresolver</a> | Enable CNAME Flattening. | false | | <a id="opt-hostresolver" href="#opt-hostresolver" title="#opt-hostresolver">hostresolver</a> | Enable CNAME Flattening. | false |
| <a id="opt-hostresolver-cnameflattening" href="#opt-hostresolver-cnameflattening" title="#opt-hostresolver-cnameflattening">hostresolver.cnameflattening</a> | A flag to enable/disable CNAME flattening | false | | <a id="opt-hostresolver-cnameflattening" href="#opt-hostresolver-cnameflattening" title="#opt-hostresolver-cnameflattening">hostresolver.cnameflattening</a> | A flag to enable/disable CNAME flattening | false |
@@ -90,6 +90,7 @@ additionalArguments:
| <a id="opt-asDefault" href="#opt-asDefault" title="#opt-asDefault">`asDefault`</a> | Mark the `entryPoint` to be in the list of default `entryPoints`.<br /> `entryPoints`in this list are used (by default) on HTTP and TCP routers that do not define their own `entryPoints` option.<br /> More information [here](#asdefault). | false | No | | <a id="opt-asDefault" href="#opt-asDefault" title="#opt-asDefault">`asDefault`</a> | Mark the `entryPoint` to be in the list of default `entryPoints`.<br /> `entryPoints`in this list are used (by default) on HTTP and TCP routers that do not define their own `entryPoints` option.<br /> More information [here](#asdefault). | false | No |
| <a id="opt-forwardedHeaders-trustedIPs" href="#opt-forwardedHeaders-trustedIPs" title="#opt-forwardedHeaders-trustedIPs">`forwardedHeaders.trustedIPs`</a> | Set the IPs or CIDR from where Traefik trusts the forwarded headers information (`X-Forwarded-*`). | - | No | | <a id="opt-forwardedHeaders-trustedIPs" href="#opt-forwardedHeaders-trustedIPs" title="#opt-forwardedHeaders-trustedIPs">`forwardedHeaders.trustedIPs`</a> | Set the IPs or CIDR from where Traefik trusts the forwarded headers information (`X-Forwarded-*`). | - | No |
| <a id="opt-forwardedHeaders-insecure" href="#opt-forwardedHeaders-insecure" title="#opt-forwardedHeaders-insecure">`forwardedHeaders.insecure`</a> | Set the insecure mode to always trust the forwarded headers information (`X-Forwarded-*`).<br />We recommend to use this option only for tests purposes, not in production. | false | No | | <a id="opt-forwardedHeaders-insecure" href="#opt-forwardedHeaders-insecure" title="#opt-forwardedHeaders-insecure">`forwardedHeaders.insecure`</a> | Set the insecure mode to always trust the forwarded headers information (`X-Forwarded-*`).<br />We recommend to use this option only for tests purposes, not in production. | false | No |
| <a id="opt-forwardedHeaders-notAppendXForwardedFor" href="#opt-forwardedHeaders-notAppendXForwardedFor" title="#opt-forwardedHeaders-notAppendXForwardedFor">`forwardedHeaders.`<br />`notAppendXForwardedFor`</a> | When set to `true`, Traefik will not append the client's `RemoteAddr` to the `X-Forwarded-For` header. The existing header is preserved as-is. If no `X-Forwarded-For` header exists, none will be added. | false | No |
| <a id="opt-http-redirections-entryPoint-to" href="#opt-http-redirections-entryPoint-to" title="#opt-http-redirections-entryPoint-to">`http.redirections.`<br />`entryPoint.to`</a> | The target element to enable (permanent) redirecting of all incoming requests on an entry point to another one. <br /> The target element can be an entry point name (ex: `websecure`), or a port (`:443`). | - | Yes | | <a id="opt-http-redirections-entryPoint-to" href="#opt-http-redirections-entryPoint-to" title="#opt-http-redirections-entryPoint-to">`http.redirections.`<br />`entryPoint.to`</a> | The target element to enable (permanent) redirecting of all incoming requests on an entry point to another one. <br /> The target element can be an entry point name (ex: `websecure`), or a port (`:443`). | - | Yes |
| <a id="opt-http-redirections-entryPoint-scheme" href="#opt-http-redirections-entryPoint-scheme" title="#opt-http-redirections-entryPoint-scheme">`http.redirections.`<br />`entryPoint.scheme`</a> | The target scheme to use for (permanent) redirection of all incoming requests. | https | No | | <a id="opt-http-redirections-entryPoint-scheme" href="#opt-http-redirections-entryPoint-scheme" title="#opt-http-redirections-entryPoint-scheme">`http.redirections.`<br />`entryPoint.scheme`</a> | The target scheme to use for (permanent) redirection of all incoming requests. | https | No |
| <a id="opt-http-redirections-entryPoint-permanent" href="#opt-http-redirections-entryPoint-permanent" title="#opt-http-redirections-entryPoint-permanent">`http.redirections.`<br />`entryPoint.permanent`</a> | Enable permanent redirecting of all incoming requests on an entry point to another one changing the scheme. <br /> The target element, it can be an entry point name (ex: `websecure`), or a port (`:443`). | false | No | | <a id="opt-http-redirections-entryPoint-permanent" href="#opt-http-redirections-entryPoint-permanent" title="#opt-http-redirections-entryPoint-permanent">`http.redirections.`<br />`entryPoint.permanent`</a> | Enable permanent redirecting of all incoming requests on an entry point to another one changing the scheme. <br /> The target element, it can be an entry point name (ex: `websecure`), or a port (`:443`). | false | No |
+12
View File
@@ -0,0 +1,12 @@
[entryPoints]
[entryPoints.web]
address = ":8000"
[entryPoints.web.forwardedHeaders]
insecure = true
notAppendXForwardedFor = true
[api]
insecure = true
[providers.file]
filename = "{{ .DynamicConfPath }}"
@@ -0,0 +1,11 @@
[entryPoints]
[entryPoints.web]
address = ":8000"
[entryPoints.web.forwardedHeaders]
insecure = true
[api]
insecure = true
[providers.file]
filename = "{{ .DynamicConfPath }}"
@@ -0,0 +1,16 @@
[entryPoints]
[entryPoints.web]
address = ":8000"
[entryPoints.web.forwardedHeaders]
insecure = true
notAppendXForwardedFor = true
[api]
insecure = true
[experimental]
[experimental.fastProxy]
debug = true
[providers.file]
filename = "{{ .DynamicConfPath }}"
@@ -0,0 +1,15 @@
[entryPoints]
[entryPoints.web]
address = ":8000"
[entryPoints.web.forwardedHeaders]
insecure = true
[api]
insecure = true
[experimental]
[experimental.fastProxy]
debug = true
[providers.file]
filename = "{{ .DynamicConfPath }}"
@@ -0,0 +1,10 @@
[http.routers]
[http.routers.router1]
entryPoints = ["web"]
rule = "PathPrefix(`/`)"
service = "service1"
[http.services]
[http.services.service1.loadBalancer]
[[http.services.service1.loadBalancer.servers]]
url = "{{ .Server }}"
+191
View File
@@ -94,6 +94,197 @@ func (s *SimpleSuite) TestSimpleFastProxy() {
assert.GreaterOrEqual(s.T(), 1, callCount) assert.GreaterOrEqual(s.T(), 1, callCount)
} }
func (s *SimpleSuite) TestXForwardedForDisabled() {
srv1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Echo back the X-Forwarded-For header
xff := req.Header.Get("X-Forwarded-For")
_, _ = rw.Write([]byte(xff))
}))
defer srv1.Close()
dynamicConf := s.adaptFile("resources/compose/x_forwarded_for.toml", struct {
Server string
}{
Server: srv1.URL,
})
staticConf := s.adaptFile("fixtures/x_forwarded_for.toml", struct {
DynamicConfPath string
}{
DynamicConfPath: dynamicConf,
})
s.traefikCmd(withConfigFile(staticConf))
// Wait for Traefik to start
err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Test with appendXForwardedFor = false
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
// Set an existing X-Forwarded-For header
req.Header.Set("X-Forwarded-For", "1.2.3.4")
resp, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(s.T(), err)
// The backend should receive the original X-Forwarded-For header unchanged
// (Traefik should NOT append RemoteAddr when appendXForwardedFor = false)
assert.Equal(s.T(), "1.2.3.4", string(body))
}
func (s *SimpleSuite) TestXForwardedForEnabled() {
srv1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Echo back the X-Forwarded-For header
xff := req.Header.Get("X-Forwarded-For")
_, _ = rw.Write([]byte(xff))
}))
defer srv1.Close()
dynamicConf := s.adaptFile("resources/compose/x_forwarded_for.toml", struct {
Server string
}{
Server: srv1.URL,
})
// Use a config with appendXForwardedFor = true
staticConf := s.adaptFile("fixtures/x_forwarded_for_enabled.toml", struct {
DynamicConfPath string
}{
DynamicConfPath: dynamicConf,
})
s.traefikCmd(withConfigFile(staticConf))
// Wait for Traefik to start
err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Test with default appendXForwardedFor = true
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
// Set an existing X-Forwarded-For header
req.Header.Set("X-Forwarded-For", "1.2.3.4")
resp, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(s.T(), err)
// The backend should receive the X-Forwarded-For header with RemoteAddr appended
// (should be "1.2.3.4, 127.0.0.1" since the request comes from localhost)
assert.Contains(s.T(), string(body), "1.2.3.4,")
assert.Contains(s.T(), string(body), "127.0.0.1")
}
func (s *SimpleSuite) TestXForwardedForDisabledFastProxy() {
srv1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Verify FastProxy is being used
assert.Contains(s.T(), req.Header, "X-Traefik-Fast-Proxy")
// Echo back the X-Forwarded-For header
xff := req.Header.Get("X-Forwarded-For")
_, _ = rw.Write([]byte(xff))
}))
defer srv1.Close()
dynamicConf := s.adaptFile("resources/compose/x_forwarded_for.toml", struct {
Server string
}{
Server: srv1.URL,
})
staticConf := s.adaptFile("fixtures/x_forwarded_for_fastproxy.toml", struct {
DynamicConfPath string
}{
DynamicConfPath: dynamicConf,
})
s.traefikCmd(withConfigFile(staticConf))
// Wait for Traefik to start
err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Test with appendXForwardedFor = false
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
// Set an existing X-Forwarded-For header
req.Header.Set("X-Forwarded-For", "1.2.3.4")
resp, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(s.T(), err)
// The backend should receive the original X-Forwarded-For header unchanged
// (FastProxy should NOT append RemoteAddr when notAppendXForwardedFor = true)
assert.Equal(s.T(), "1.2.3.4", string(body))
}
func (s *SimpleSuite) TestXForwardedForEnabledFastProxy() {
srv1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Verify FastProxy is being used
assert.Contains(s.T(), req.Header, "X-Traefik-Fast-Proxy")
// Echo back the X-Forwarded-For header
xff := req.Header.Get("X-Forwarded-For")
_, _ = rw.Write([]byte(xff))
}))
defer srv1.Close()
dynamicConf := s.adaptFile("resources/compose/x_forwarded_for.toml", struct {
Server string
}{
Server: srv1.URL,
})
// Use a config with appendXForwardedFor = false (default)
staticConf := s.adaptFile("fixtures/x_forwarded_for_fastproxy_enabled.toml", struct {
DynamicConfPath string
}{
DynamicConfPath: dynamicConf,
})
s.traefikCmd(withConfigFile(staticConf))
// Wait for Traefik to start
err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Test with default appendXForwardedFor = true
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
// Set an existing X-Forwarded-For header
req.Header.Set("X-Forwarded-For", "1.2.3.4")
resp, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(s.T(), err)
// The backend should receive the X-Forwarded-For header with RemoteAddr appended
// (FastProxy should append RemoteAddr when notAppendXForwardedFor = false)
// (should be "1.2.3.4, 127.0.0.1" since the request comes from localhost)
assert.Contains(s.T(), string(body), "1.2.3.4,")
assert.Contains(s.T(), string(body), "127.0.0.1")
}
func (s *SimpleSuite) TestWithWebConfig() { func (s *SimpleSuite) TestWithWebConfig() {
s.cmdTraefik(withConfigFile("fixtures/simple_web.toml")) s.cmdTraefik(withConfigFile("fixtures/simple_web.toml"))
+4 -3
View File
@@ -128,9 +128,10 @@ type TLSConfig struct {
// ForwardedHeaders Trust client forwarding headers. // ForwardedHeaders Trust client forwarding headers.
type ForwardedHeaders struct { type ForwardedHeaders struct {
Insecure bool `description:"Trust all forwarded headers." json:"insecure,omitempty" toml:"insecure,omitempty" yaml:"insecure,omitempty" export:"true"` Insecure bool `description:"Trust all forwarded headers." json:"insecure,omitempty" toml:"insecure,omitempty" yaml:"insecure,omitempty" export:"true"`
TrustedIPs []string `description:"Trust only forwarded headers from selected IPs." json:"trustedIPs,omitempty" toml:"trustedIPs,omitempty" yaml:"trustedIPs,omitempty"` TrustedIPs []string `description:"Trust only forwarded headers from selected IPs." json:"trustedIPs,omitempty" toml:"trustedIPs,omitempty" yaml:"trustedIPs,omitempty"`
Connection []string `description:"List of Connection headers that are allowed to pass through the middleware chain before being removed." json:"connection,omitempty" toml:"connection,omitempty" yaml:"connection,omitempty"` Connection []string `description:"List of Connection headers that are allowed to pass through the middleware chain before being removed." json:"connection,omitempty" toml:"connection,omitempty" yaml:"connection,omitempty"`
NotAppendXForwardedFor bool `description:"Disable appending RemoteAddr to X-Forwarded-For header. Defaults to false (appending is enabled)." json:"notAppendXForwardedFor,omitempty" toml:"notAppendXForwardedFor,omitempty" yaml:"notAppendXForwardedFor,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
} }
// ProxyProtocol contains Proxy-Protocol configuration. // ProxyProtocol contains Proxy-Protocol configuration.
+3 -2
View File
@@ -112,8 +112,9 @@ type CertificateResolver struct {
// Global holds the global configuration. // Global holds the global configuration.
type Global struct { type Global struct {
CheckNewVersion bool `description:"Periodically check if a new version has been released." json:"checkNewVersion,omitempty" toml:"checkNewVersion,omitempty" yaml:"checkNewVersion,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` CheckNewVersion bool `description:"Periodically check if a new version has been released." json:"checkNewVersion,omitempty" toml:"checkNewVersion,omitempty" yaml:"checkNewVersion,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
SendAnonymousUsage bool `description:"Periodically send anonymous usage statistics. If the option is not specified, it will be disabled by default." json:"sendAnonymousUsage,omitempty" toml:"sendAnonymousUsage,omitempty" yaml:"sendAnonymousUsage,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` SendAnonymousUsage bool `description:"Periodically send anonymous usage statistics. If the option is not specified, it will be disabled by default." json:"sendAnonymousUsage,omitempty" toml:"sendAnonymousUsage,omitempty" yaml:"sendAnonymousUsage,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
NotAppendXForwardedFor bool `description:"Disable appending RemoteAddr to X-Forwarded-For header. Defaults to false (appending is enabled)." json:"notAppendXForwardedFor,omitempty" toml:"notAppendXForwardedFor,omitempty" yaml:"notAppendXForwardedFor,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
} }
// ServersTransport options to configure communication between Traefik and the servers. // ServersTransport options to configure communication between Traefik and the servers.
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/traefik/traefik/v3/pkg/ip" "github.com/traefik/traefik/v3/pkg/ip"
"github.com/traefik/traefik/v3/pkg/proxy/httputil"
"golang.org/x/net/http/httpguts" "golang.org/x/net/http/httpguts"
) )
@@ -47,16 +48,17 @@ var xHeaders = []string{
// Unless insecure is set, // Unless insecure is set,
// it first removes all the existing values for those headers if the remote address is not one of the trusted ones. // it first removes all the existing values for those headers if the remote address is not one of the trusted ones.
type XForwarded struct { type XForwarded struct {
insecure bool insecure bool
trustedIPs []string trustedIPs []string
connectionHeaders []string connectionHeaders []string
ipChecker *ip.Checker notAppendXForwardedFor bool
next http.Handler ipChecker *ip.Checker
hostname string next http.Handler
hostname string
} }
// NewXForwarded creates a new XForwarded. // NewXForwarded creates a new XForwarded.
func NewXForwarded(insecure bool, trustedIPs []string, connectionHeaders []string, next http.Handler) (*XForwarded, error) { func NewXForwarded(insecure bool, trustedIPs []string, connectionHeaders []string, notAppendXForwardedFor bool, next http.Handler) (*XForwarded, error) {
var ipChecker *ip.Checker var ipChecker *ip.Checker
if len(trustedIPs) > 0 { if len(trustedIPs) > 0 {
var err error var err error
@@ -72,12 +74,13 @@ func NewXForwarded(insecure bool, trustedIPs []string, connectionHeaders []strin
} }
return &XForwarded{ return &XForwarded{
insecure: insecure, insecure: insecure,
trustedIPs: trustedIPs, trustedIPs: trustedIPs,
connectionHeaders: connectionHeaders, connectionHeaders: connectionHeaders,
ipChecker: ipChecker, notAppendXForwardedFor: notAppendXForwardedFor,
next: next, ipChecker: ipChecker,
hostname: hostname, next: next,
hostname: hostname,
}, nil }, nil
} }
@@ -198,6 +201,10 @@ func (x *XForwarded) ServeHTTP(w http.ResponseWriter, r *http.Request) {
x.removeConnectionHeaders(r) x.removeConnectionHeaders(r)
if x.notAppendXForwardedFor {
r = r.WithContext(httputil.SetNotAppendXFF(r.Context()))
}
x.next.ServeHTTP(w, r) x.next.ServeHTTP(w, r)
} }
@@ -516,7 +516,7 @@ func TestServeHTTP(t *testing.T) {
} }
} }
m, err := NewXForwarded(test.insecure, test.trustedIps, test.connectionHeaders, m, err := NewXForwarded(test.insecure, test.trustedIps, test.connectionHeaders, false,
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
require.NoError(t, err) require.NoError(t, err)
@@ -655,7 +655,7 @@ func TestConnection(t *testing.T) {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
t.Parallel() t.Parallel()
forwarded, err := NewXForwarded(true, nil, test.connectionHeaders, nil) forwarded, err := NewXForwarded(true, nil, test.connectionHeaders, false, nil)
require.NoError(t, err) require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "https://localhost", nil) req := httptest.NewRequest(http.MethodGet, "https://localhost", nil)
+13 -11
View File
@@ -212,18 +212,20 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
outReq.Header.SetMethod(req.Method) outReq.Header.SetMethod(req.Method)
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { if !proxyhttputil.ShouldNotAppendXFF(req.Context()) {
// If we aren't the first proxy retain prior if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
// X-Forwarded-For information as a comma+space // If we aren't the first proxy retain prior
// separated list and fold multiple headers into one. // X-Forwarded-For information as a comma+space
prior, ok := req.Header["X-Forwarded-For"] // separated list and fold multiple headers into one.
if len(prior) > 0 { prior, ok := req.Header["X-Forwarded-For"]
clientIP = strings.Join(prior, ", ") + ", " + clientIP if len(prior) > 0 {
} clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
omit := ok && prior == nil // Go Issue 38079: nil now means don't populate the header omit := ok && prior == nil // Go Issue 38079: nil now means don't populate the header
if !omit { if !omit {
outReq.Header.Set("X-Forwarded-For", clientIP) outReq.Header.Set("X-Forwarded-For", clientIP)
}
} }
} }
+85
View File
@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/config/static" "github.com/traefik/traefik/v3/pkg/config/static"
proxyhttputil "github.com/traefik/traefik/v3/pkg/proxy/httputil"
"github.com/traefik/traefik/v3/pkg/testhelpers" "github.com/traefik/traefik/v3/pkg/testhelpers"
) )
@@ -406,6 +407,90 @@ func TestTransferEncodingChunked(t *testing.T) {
assert.Equal(t, "chunk 0\nchunk 1\nchunk 2\n", string(body)) assert.Equal(t, "chunk 0\nchunk 1\nchunk 2\n", string(body))
} }
func TestXForwardedFor(t *testing.T) {
testCases := []struct {
desc string
notAppendXFF bool
incomingXFF string
expectedXFF string
expectedXFFNotPresent bool
}{
{
desc: "appends RemoteAddr when notAppendXFF is false",
notAppendXFF: false,
incomingXFF: "",
expectedXFF: "192.0.2.1",
},
{
desc: "appends RemoteAddr to existing XFF when notAppendXFF is false",
notAppendXFF: false,
incomingXFF: "203.0.113.1",
expectedXFF: "203.0.113.1, 192.0.2.1",
},
{
desc: "does not append RemoteAddr when notAppendXFF is true and no incoming XFF",
notAppendXFF: true,
incomingXFF: "",
expectedXFFNotPresent: true,
},
{
desc: "preserves existing XFF when notAppendXFF is true",
notAppendXFF: true,
incomingXFF: "203.0.113.1",
expectedXFF: "203.0.113.1",
},
{
desc: "preserves multiple XFF values when notAppendXFF is true",
notAppendXFF: true,
incomingXFF: "203.0.113.1, 198.51.100.1",
expectedXFF: "203.0.113.1, 198.51.100.1",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
var receivedXFF string
var xffPresent bool
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
receivedXFF = req.Header.Get("X-Forwarded-For")
xffPresent = req.Header.Get("X-Forwarded-For") != "" || len(req.Header["X-Forwarded-For"]) > 0
rw.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
builder := NewProxyBuilder(&transportManagerMock{}, static.FastProxyConfig{})
proxyHandler, err := builder.Build("", testhelpers.MustParseURL(server.URL), true, false)
require.NoError(t, err)
ctx := t.Context()
if test.notAppendXFF {
ctx = proxyhttputil.SetNotAppendXFF(ctx)
}
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req = req.WithContext(ctx)
req.RemoteAddr = "192.0.2.1:12345"
if test.incomingXFF != "" {
req.Header.Set("X-Forwarded-For", test.incomingXFF)
}
res := httptest.NewRecorder()
proxyHandler.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
if test.expectedXFFNotPresent {
assert.False(t, xffPresent, "X-Forwarded-For header should not be present")
} else {
assert.Equal(t, test.expectedXFF, receivedXFF)
}
})
}
}
type transportManagerMock struct { type transportManagerMock struct {
tlsConfig *tls.Config tlsConfig *tls.Config
} }
+80 -19
View File
@@ -19,17 +19,41 @@ import (
"golang.org/x/net/http/httpguts" "golang.org/x/net/http/httpguts"
) )
type key string
const ( const (
// StatusClientClosedRequest non-standard HTTP status code for client disconnection. // StatusClientClosedRequest non-standard HTTP status code for client disconnection.
StatusClientClosedRequest = 499 StatusClientClosedRequest = 499
// StatusClientClosedRequestText non-standard HTTP status for client disconnection. // StatusClientClosedRequestText non-standard HTTP status for client disconnection.
StatusClientClosedRequestText = "Client Closed Request" StatusClientClosedRequestText = "Client Closed Request"
notAppendXFFKey key = "NotAppendXFF"
) )
// SetNotAppendXFF indicates xff should not be appended.
func SetNotAppendXFF(ctx context.Context) context.Context {
return context.WithValue(ctx, notAppendXFFKey, true)
}
// ShouldNotAppendXFF returns whether X-Forwarded-For should not be appended.
func ShouldNotAppendXFF(ctx context.Context) bool {
val := ctx.Value(notAppendXFFKey)
if val == nil {
return false
}
notAppendXFF, ok := val.(bool)
if !ok {
return false
}
return notAppendXFF
}
func buildSingleHostProxy(target *url.URL, passHostHeader bool, preservePath 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{ return &httputil.ReverseProxy{
Director: directorBuilder(target, passHostHeader, preservePath), Rewrite: rewriteRequestBuilder(target, passHostHeader, preservePath),
Transport: roundTripper, Transport: roundTripper,
FlushInterval: flushInterval, FlushInterval: flushInterval,
BufferPool: bufferPool, BufferPool: bufferPool,
@@ -38,45 +62,82 @@ func buildSingleHostProxy(target *url.URL, passHostHeader bool, preservePath boo
} }
} }
func directorBuilder(target *url.URL, passHostHeader bool, preservePath bool) func(req *http.Request) { func rewriteRequestBuilder(target *url.URL, passHostHeader bool, preservePath bool) func(*httputil.ProxyRequest) {
return func(outReq *http.Request) { return func(pr *httputil.ProxyRequest) {
outReq.URL.Scheme = target.Scheme copyForwardedHeader(pr.Out.Header, pr.In.Header)
outReq.URL.Host = target.Host if !ShouldNotAppendXFF(pr.In.Context()) {
if clientIP, _, err := net.SplitHostPort(pr.In.RemoteAddr); err == nil {
// If we aren't the first proxy retain prior
// X-Forwarded-For information as a comma+space
// separated list and fold multiple headers into one.
prior, ok := pr.Out.Header["X-Forwarded-For"]
omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
if len(prior) > 0 {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
if !omit {
pr.Out.Header.Set("X-Forwarded-For", clientIP)
}
}
}
u := outReq.URL pr.Out.URL.Scheme = target.Scheme
if outReq.RequestURI != "" { pr.Out.URL.Host = target.Host
parsedURL, err := url.ParseRequestURI(outReq.RequestURI)
u := pr.Out.URL
if pr.Out.RequestURI != "" {
parsedURL, err := url.ParseRequestURI(pr.Out.RequestURI)
if err == nil { if err == nil {
u = parsedURL u = parsedURL
} }
} }
outReq.URL.Path = u.Path pr.Out.URL.Path = u.Path
outReq.URL.RawPath = u.RawPath pr.Out.URL.RawPath = u.RawPath
if preservePath { if preservePath {
outReq.URL.Path, outReq.URL.RawPath = JoinURLPath(target, u) pr.Out.URL.Path, pr.Out.URL.RawPath = JoinURLPath(target, u)
} }
// If a plugin/middleware adds semicolons in query params, they should be urlEncoded. // If a plugin/middleware adds semicolons in query params, they should be urlEncoded.
outReq.URL.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&") pr.Out.URL.RawQuery = strings.ReplaceAll(u.RawQuery, ";", "&")
outReq.RequestURI = "" // Outgoing request should not have RequestURI pr.Out.RequestURI = "" // Outgoing request should not have RequestURI
outReq.Proto = "HTTP/1.1" pr.Out.Proto = "HTTP/1.1"
outReq.ProtoMajor = 1 pr.Out.ProtoMajor = 1
outReq.ProtoMinor = 1 pr.Out.ProtoMinor = 1
// Do not pass client Host header unless option PassHostHeader is set. // Do not pass client Host header unless option PassHostHeader is set.
if !passHostHeader { if !passHostHeader {
outReq.Host = outReq.URL.Host pr.Out.Host = pr.Out.URL.Host
} }
if isWebSocketUpgrade(outReq) { if isWebSocketUpgrade(pr.Out) {
cleanWebSocketHeaders(outReq) cleanWebSocketHeaders(pr.Out)
} }
} }
} }
// copyForwardedHeader copies header that are removed by the reverseProxy when a rewriteRequest is used.
func copyForwardedHeader(dst, src http.Header) {
prior, ok := src["X-Forwarded-For"]
if ok {
dst["X-Forwarded-For"] = prior
}
prior, ok = src["Forwarded"]
if ok {
dst["Forwarded"] = prior
}
prior, ok = src["X-Forwarded-Host"]
if ok {
dst["X-Forwarded-Host"] = prior
}
prior, ok = src["X-Forwarded-Proto"]
if ok {
dst["X-Forwarded-Proto"] = prior
}
}
// cleanWebSocketHeaders Even if the websocket RFC says that headers should be case-insensitive, // cleanWebSocketHeaders Even if the websocket RFC says that headers should be case-insensitive,
// some servers need Sec-WebSocket-Key, Sec-WebSocket-Extensions, Sec-WebSocket-Accept, // some servers need Sec-WebSocket-Key, Sec-WebSocket-Extensions, Sec-WebSocket-Accept,
// Sec-WebSocket-Protocol and Sec-WebSocket-Version to be case-sensitive. // Sec-WebSocket-Protocol and Sec-WebSocket-Version to be case-sensitive.
+48 -14
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/http/httputil"
"net/url" "net/url"
"testing" "testing"
@@ -13,7 +14,7 @@ import (
"github.com/traefik/traefik/v3/pkg/testhelpers" "github.com/traefik/traefik/v3/pkg/testhelpers"
) )
func Test_directorBuilder(t *testing.T) { func Test_rewriteRequestBuilder(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
target *url.URL target *url.URL
@@ -25,6 +26,7 @@ func Test_directorBuilder(t *testing.T) {
expectedPath string expectedPath string
expectedRawPath string expectedRawPath string
expectedQuery string expectedQuery string
notAppendXFF bool
}{ }{
{ {
name: "Basic proxy", name: "Basic proxy",
@@ -37,6 +39,18 @@ func Test_directorBuilder(t *testing.T) {
expectedPath: "/test", expectedPath: "/test",
expectedQuery: "param=value", expectedQuery: "param=value",
}, },
{
name: "Basic proxy - notAppendXFF",
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",
notAppendXFF: true,
},
{ {
name: "HTTPS target", name: "HTTPS target",
target: testhelpers.MustParseURL("https://secure.example.com"), target: testhelpers.MustParseURL("https://secure.example.com"),
@@ -85,21 +99,41 @@ func Test_directorBuilder(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
director := directorBuilder(test.target, test.passHostHeader, test.preservePath) rewriteRequest := rewriteRequestBuilder(test.target, test.passHostHeader, test.preservePath)
req := httptest.NewRequest(http.MethodGet, test.incomingURL, http.NoBody) ctx := t.Context()
director(req) if test.notAppendXFF {
ctx = SetNotAppendXFF(ctx)
}
assert.Equal(t, test.expectedScheme, req.URL.Scheme) reqIn := httptest.NewRequest(http.MethodGet, test.incomingURL, http.NoBody)
assert.Equal(t, test.expectedHost, req.Host) reqIn = reqIn.WithContext(ctx)
assert.Equal(t, test.expectedPath, req.URL.Path) reqIn.Header.Add("X-Forwarded-For", "1.2.3.4")
assert.Equal(t, test.expectedRawPath, req.URL.RawPath) reqIn.RemoteAddr = "127.0.0.1:1234"
assert.Equal(t, test.expectedQuery, req.URL.RawQuery)
assert.Empty(t, req.RequestURI) reqOut := httptest.NewRequest(http.MethodGet, test.incomingURL, http.NoBody)
assert.Equal(t, "HTTP/1.1", req.Proto) pr := &httputil.ProxyRequest{
assert.Equal(t, 1, req.ProtoMajor) In: reqIn,
assert.Equal(t, 1, req.ProtoMinor) Out: reqOut,
assert.False(t, !test.passHostHeader && req.Host != req.URL.Host) }
rewriteRequest(pr)
if test.notAppendXFF {
assert.Equal(t, "1.2.3.4", reqOut.Header.Get("X-Forwarded-For"))
} else {
// When not disabled, X-Forwarded-For should have RemoteAddr appended
assert.Equal(t, "1.2.3.4, 127.0.0.1", reqOut.Header.Get("X-Forwarded-For"))
}
assert.Equal(t, test.expectedScheme, reqOut.URL.Scheme)
assert.Equal(t, test.expectedHost, reqOut.Host)
assert.Equal(t, test.expectedPath, reqOut.URL.Path)
assert.Equal(t, test.expectedRawPath, reqOut.URL.RawPath)
assert.Equal(t, test.expectedQuery, reqOut.URL.RawQuery)
assert.Empty(t, reqOut.RequestURI)
assert.Equal(t, "HTTP/1.1", reqOut.Proto)
assert.Equal(t, 1, reqOut.ProtoMajor)
assert.Equal(t, 1, reqOut.ProtoMinor)
assert.False(t, !test.passHostHeader && reqOut.Host != reqOut.URL.Host)
}) })
} }
} }
+1
View File
@@ -650,6 +650,7 @@ func newHTTPServer(ctx context.Context, ln net.Listener, configuration *static.E
configuration.ForwardedHeaders.Insecure, configuration.ForwardedHeaders.Insecure,
configuration.ForwardedHeaders.TrustedIPs, configuration.ForwardedHeaders.TrustedIPs,
configuration.ForwardedHeaders.Connection, configuration.ForwardedHeaders.Connection,
configuration.ForwardedHeaders.NotAppendXForwardedFor,
next) next)
if err != nil { if err != nil {
return nil, err return nil, err