fix(proxy): send a trustworthy client IP to upstream containers
Replace client-supplied X-Real-IP, X-Forwarded-For and Cf-Connecting-Ip (including underscore spellings) with the client IP resolved by GetClientIP, using the same trusted-proxy rules as logs and rate limits. Cf-Connecting-Ip is only kept for trusted Cloudflare peers when it matches the resolved IP. GetClientIP now joins all X-Forwarded-For lines and ignores a repeated X-Real-IP, so a proxy adding a new XFF line cannot be spoofed. Refs #17
- commit
- 6e4d05dd8aeb706ce815da479fd9fbb770b7ce23
- author
- brice · · 2026-10-16 12:52 UTC
- parent
- db3477e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +298 -28
M docs/config/server.md +19 -0
| @@ -362,6 +362,25 @@ proxy_allowed_ips = [ | |||
| 362 | 362 | ||
| 363 | 363 | **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges. | |
| 364 | 364 | ||
| 365 | + | ## Client IP Headers | |
| 366 | + | ||
| 367 | + | Gordon sets the client IP on every request it proxies to a container. Apps can trust these headers without any extra proxy in front, as long as the container is only reachable through Gordon (no published host ports): | |
| 368 | + | ||
| 369 | + | | Header | Value sent to the container | | |
| 370 | + | |--------|-----------------------------| | |
| 371 | + | | `X-Real-IP` | The resolved client IP. Any value sent by the client is replaced, including `X_Real_IP`-style spellings. | | |
| 372 | + | | `X-Forwarded-For` | The same single resolved client IP. | | |
| 373 | + | | `Cf-Connecting-Ip` | Kept only when the peer is a trusted Cloudflare address and the value is the resolved client IP, otherwise removed. | | |
| 374 | + | | `X-Forwarded-Proto` / `X-Forwarded-Host` | Request scheme and route host. A trusted proxy's `X-Forwarded-Proto` is kept. | | |
| 375 | + | ||
| 376 | + | The client IP is resolved with the same rules as Gordon's logs and rate limiter, using [`api.rate_limit.trusted_proxies`](./rate-limiting.md#trusted_proxies): | |
| 377 | + | ||
| 378 | + | - **Untrusted peer:** the peer address. | |
| 379 | + | - **Trusted Cloudflare peer:** a valid `Cf-Connecting-Ip`, otherwise the trusted proxy rules below. | |
| 380 | + | - **Other trusted proxy:** the right-most untrusted `X-Forwarded-For` hop (the left-most hop if all are trusted), then a single `X-Real-IP`, then the peer address. | |
| 381 | + | ||
| 382 | + | IPv4-mapped IPv6 addresses are sent as plain IPv4. For apps that read the client IP from a header (for example `ADDRESS_HEADER=x-real-ip` in SvelteKit's adapter-node), use `X-Real-IP`. | |
| 383 | + | ||
| 365 | 384 | ## Registry Listen Address | |
| 366 | 385 | ||
| 367 | 386 | The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway. | |
| @@ -362,6 +362,25 @@ proxy_allowed_ips = [ | |||||
| 362 | 362 | ||||
| 363 | **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges. | 363 | **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges. | ||
| 364 | 364 | ||||
| 365 | + | ## Client IP Headers | |||
| 366 | + | ||||
| 367 | + | Gordon sets the client IP on every request it proxies to a container. Apps can trust these headers without any extra proxy in front, as long as the container is only reachable through Gordon (no published host ports): | |||
| 368 | + | ||||
| 369 | + | | Header | Value sent to the container | | |||
| 370 | + | |--------|-----------------------------| | |||
| 371 | + | | `X-Real-IP` | The resolved client IP. Any value sent by the client is replaced, including `X_Real_IP`-style spellings. | | |||
| 372 | + | | `X-Forwarded-For` | The same single resolved client IP. | | |||
| 373 | + | | `Cf-Connecting-Ip` | Kept only when the peer is a trusted Cloudflare address and the value is the resolved client IP, otherwise removed. | | |||
| 374 | + | | `X-Forwarded-Proto` / `X-Forwarded-Host` | Request scheme and route host. A trusted proxy's `X-Forwarded-Proto` is kept. | | |||
| 375 | + | ||||
| 376 | + | The client IP is resolved with the same rules as Gordon's logs and rate limiter, using [`api.rate_limit.trusted_proxies`](./rate-limiting.md#trusted_proxies): | |||
| 377 | + | ||||
| 378 | + | - **Untrusted peer:** the peer address. | |||
| 379 | + | - **Trusted Cloudflare peer:** a valid `Cf-Connecting-Ip`, otherwise the trusted proxy rules below. | |||
| 380 | + | - **Other trusted proxy:** the right-most untrusted `X-Forwarded-For` hop (the left-most hop if all are trusted), then a single `X-Real-IP`, then the peer address. | |||
| 381 | + | ||||
| 382 | + | IPv4-mapped IPv6 addresses are sent as plain IPv4. For apps that read the client IP from a header (for example `ADDRESS_HEADER=x-real-ip` in SvelteKit's adapter-node), use `X-Real-IP`. | |||
| 383 | + | ||||
| 365 | ## Registry Listen Address | 384 | ## Registry Listen Address | ||
| 366 | 385 | ||||
| 367 | The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway. | 386 | The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway. | ||
M internal/adapters/in/http/middleware/clientip.go +51 -27
| @@ -84,38 +84,62 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | // Check X-Forwarded-For for proxied requests. | |
| 88 | - | // SECURITY: Parse from right to left and return the first untrusted IP. | |
| 89 | - | // This prevents spoofing when a trusted proxy appends to an attacker- | |
| 90 | - | // supplied XFF chain. | |
| 91 | - | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 92 | - | parts := strings.Split(xff, ",") | |
| 93 | - | validChain := make([]string, 0, len(parts)) | |
| 94 | - | for _, part := range parts { | |
| 95 | - | if ip := normalizeIP(part); ip != "" { | |
| 96 | - | validChain = append(validChain, ip) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | for i := len(validChain) - 1; i >= 0; i-- { | |
| 101 | - | if !httphelper.IsTrustedProxy(validChain[i], trustedNets) { | |
| 102 | - | return validChain[i] | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | if len(validChain) > 0 { | |
| 107 | - | return validChain[0] | |
| 108 | - | } | |
| 87 | + | if ip := forwardedForClientIP(r, trustedNets); ip != "" { | |
| 88 | + | return ip | |
| 109 | 89 | } | |
| 110 | 90 | ||
| 111 | - | // Check X-Real-IP | |
| 112 | - | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 113 | - | if ip := normalizeIP(xri); ip != "" { | |
| 114 | - | return ip | |
| 115 | - | } | |
| 91 | + | if ip := realIPHeader(r); ip != "" { | |
| 92 | + | return ip | |
| 116 | 93 | } | |
| 117 | 94 | } | |
| 118 | 95 | ||
| 119 | 96 | // Fall back to RemoteAddr (direct connection IP) | |
| 120 | 97 | return remoteIP | |
| 121 | 98 | } | |
| 99 | + | ||
| 100 | + | // forwardedForClientIP returns the right-most untrusted X-Forwarded-For hop. | |
| 101 | + | // SECURITY: Parse from right to left so a trusted proxy appending to an | |
| 102 | + | // attacker-supplied chain cannot be spoofed. All header lines are joined, since | |
| 103 | + | // some proxies add a new X-Forwarded-For line instead of appending to the last. | |
| 104 | + | func forwardedForClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |
| 105 | + | values := r.Header.Values("X-Forwarded-For") | |
| 106 | + | if len(values) == 0 { | |
| 107 | + | return "" | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | parts := strings.Split(strings.Join(values, ","), ",") | |
| 111 | + | validChain := make([]string, 0, len(parts)) | |
| 112 | + | for _, part := range parts { | |
| 113 | + | if ip := normalizeIP(part); ip != "" { | |
| 114 | + | validChain = append(validChain, ip) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | for i := len(validChain) - 1; i >= 0; i-- { | |
| 119 | + | if !httphelper.IsTrustedProxy(validChain[i], trustedNets) { | |
| 120 | + | return validChain[i] | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | if len(validChain) > 0 { | |
| 125 | + | return validChain[0] | |
| 126 | + | } | |
| 127 | + | return "" | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // realIPHeader returns X-Real-IP only when it is set exactly once, since a | |
| 131 | + | // repeated header means one of the values may come from the client. | |
| 132 | + | func realIPHeader(r *http.Request) string { | |
| 133 | + | values := r.Header.Values("X-Real-IP") | |
| 134 | + | if len(values) != 1 { | |
| 135 | + | return "" | |
| 136 | + | } | |
| 137 | + | return normalizeIP(values[0]) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // IsTrustedCloudflarePeer reports whether the request's immediate peer is both | |
| 141 | + | // a trusted proxy and a published Cloudflare address. Only then does | |
| 142 | + | // Cf-Connecting-Ip carry a client IP set by Cloudflare rather than by the client. | |
| 143 | + | func IsTrustedCloudflarePeer(r *http.Request, trustedNets []*net.IPNet) bool { | |
| 144 | + | return httphelper.IsTrustedSource(r, trustedNets) && httphelper.IsTrustedSource(r, cloudflareNets) | |
| 145 | + | } | |
| @@ -84,38 +84,62 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||||
| 84 | } | 84 | } | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | - | // Check X-Forwarded-For for proxied requests. | 87 | + | if ip := forwardedForClientIP(r, trustedNets); ip != "" { |
| 88 | - | // SECURITY: Parse from right to left and return the first untrusted IP. | 88 | + | return ip |
| 89 | - | // This prevents spoofing when a trusted proxy appends to an attacker- | |||
| 90 | - | // supplied XFF chain. | |||
| 91 | - | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |||
| 92 | - | parts := strings.Split(xff, ",") | |||
| 93 | - | validChain := make([]string, 0, len(parts)) | |||
| 94 | - | for _, part := range parts { | |||
| 95 | - | if ip := normalizeIP(part); ip != "" { | |||
| 96 | - | validChain = append(validChain, ip) | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | for i := len(validChain) - 1; i >= 0; i-- { | |||
| 101 | - | if !httphelper.IsTrustedProxy(validChain[i], trustedNets) { | |||
| 102 | - | return validChain[i] | |||
| 103 | - | } | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | if len(validChain) > 0 { | |||
| 107 | - | return validChain[0] | |||
| 108 | - | } | |||
| 109 | } | 89 | } | ||
| 110 | 90 | ||||
| 111 | - | // Check X-Real-IP | 91 | + | if ip := realIPHeader(r); ip != "" { |
| 112 | - | if xri := r.Header.Get("X-Real-IP"); xri != "" { | 92 | + | return ip |
| 113 | - | if ip := normalizeIP(xri); ip != "" { | |||
| 114 | - | return ip | |||
| 115 | - | } | |||
| 116 | } | 93 | } | ||
| 117 | } | 94 | } | ||
| 118 | 95 | ||||
| 119 | // Fall back to RemoteAddr (direct connection IP) | 96 | // Fall back to RemoteAddr (direct connection IP) | ||
| 120 | return remoteIP | 97 | return remoteIP | ||
| 121 | } | 98 | } | ||
| 99 | + | ||||
| 100 | + | // forwardedForClientIP returns the right-most untrusted X-Forwarded-For hop. | |||
| 101 | + | // SECURITY: Parse from right to left so a trusted proxy appending to an | |||
| 102 | + | // attacker-supplied chain cannot be spoofed. All header lines are joined, since | |||
| 103 | + | // some proxies add a new X-Forwarded-For line instead of appending to the last. | |||
| 104 | + | func forwardedForClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 105 | + | values := r.Header.Values("X-Forwarded-For") | |||
| 106 | + | if len(values) == 0 { | |||
| 107 | + | return "" | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | parts := strings.Split(strings.Join(values, ","), ",") | |||
| 111 | + | validChain := make([]string, 0, len(parts)) | |||
| 112 | + | for _, part := range parts { | |||
| 113 | + | if ip := normalizeIP(part); ip != "" { | |||
| 114 | + | validChain = append(validChain, ip) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | for i := len(validChain) - 1; i >= 0; i-- { | |||
| 119 | + | if !httphelper.IsTrustedProxy(validChain[i], trustedNets) { | |||
| 120 | + | return validChain[i] | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | if len(validChain) > 0 { | |||
| 125 | + | return validChain[0] | |||
| 126 | + | } | |||
| 127 | + | return "" | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // realIPHeader returns X-Real-IP only when it is set exactly once, since a | |||
| 131 | + | // repeated header means one of the values may come from the client. | |||
| 132 | + | func realIPHeader(r *http.Request) string { | |||
| 133 | + | values := r.Header.Values("X-Real-IP") | |||
| 134 | + | if len(values) != 1 { | |||
| 135 | + | return "" | |||
| 136 | + | } | |||
| 137 | + | return normalizeIP(values[0]) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // IsTrustedCloudflarePeer reports whether the request's immediate peer is both | |||
| 141 | + | // a trusted proxy and a published Cloudflare address. Only then does | |||
| 142 | + | // Cf-Connecting-Ip carry a client IP set by Cloudflare rather than by the client. | |||
| 143 | + | func IsTrustedCloudflarePeer(r *http.Request, trustedNets []*net.IPNet) bool { | |||
| 144 | + | return httphelper.IsTrustedSource(r, trustedNets) && httphelper.IsTrustedSource(r, cloudflareNets) | |||
| 145 | + | } | |||
M internal/adapters/in/http/proxy/forwarder.go +44 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/http/httputil" | |
| 12 | 12 | "net/url" | |
| 13 | + | "slices" | |
| 13 | 14 | "strconv" | |
| 14 | 15 | "strings" | |
| 15 | 16 | "time" | |
| @@ -17,6 +18,7 @@ import ( | |||
| 17 | 18 | "github.com/bnema/zerowrap" | |
| 18 | 19 | ||
| 19 | 20 | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 21 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 20 | 22 | "github.com/bnema/gordon/internal/domain" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| @@ -235,13 +237,15 @@ func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string { | |||
| 235 | 237 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | |
| 236 | 238 | // | |
| 237 | 239 | // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the | |
| 238 | - | // request originates from a trusted upstream proxy. | |
| 240 | + | // request originates from a trusted upstream proxy, and to resolve the client IP | |
| 241 | + | // sent upstream in X-Real-IP and X-Forwarded-For. | |
| 239 | 242 | func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { | |
| 240 | 243 | return &httputil.ReverseProxy{ | |
| 241 | 244 | Rewrite: func(pr *httputil.ProxyRequest) { | |
| 242 | 245 | existingProto := pr.In.Header.Get("X-Forwarded-Proto") | |
| 243 | 246 | pr.SetURL(opts.targetURL) | |
| 244 | 247 | pr.SetXForwarded() | |
| 248 | + | setClientIPHeaders(pr, opts.trustedNets) | |
| 245 | 249 | if opts.hostHeader != "" { | |
| 246 | 250 | pr.Out.Host = opts.hostHeader | |
| 247 | 251 | } | |
| @@ -262,6 +266,45 @@ func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { | |||
| 262 | 266 | } | |
| 263 | 267 | } | |
| 264 | 268 | ||
| 269 | + | // clientIPHeaders are the headers carrying a client IP that Gordon rewrites | |
| 270 | + | // before proxying to a container, in canonical form. | |
| 271 | + | var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"} | |
| 272 | + | ||
| 273 | + | // setClientIPHeaders replaces client-supplied IP headers with the client IP | |
| 274 | + | // resolved by the same trusted-proxy rules Gordon uses for its own logs and | |
| 275 | + | // rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For. | |
| 276 | + | // Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted | |
| 277 | + | // Cloudflare peer. | |
| 278 | + | func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) { | |
| 279 | + | cfIP := pr.Out.Header.Get("Cf-Connecting-Ip") | |
| 280 | + | deleteClientIPHeaders(pr.Out.Header) | |
| 281 | + | ||
| 282 | + | clientIP := middleware.GetClientIP(pr.In, trustedNets) | |
| 283 | + | parsed := net.ParseIP(clientIP) | |
| 284 | + | if parsed == nil { | |
| 285 | + | // No usable peer address (e.g. a Unix socket): send no client IP at all. | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | pr.Out.Header.Set("X-Real-IP", clientIP) | |
| 289 | + | pr.Out.Header.Set("X-Forwarded-For", clientIP) | |
| 290 | + | if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) { | |
| 291 | + | pr.Out.Header.Set("Cf-Connecting-Ip", clientIP) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // deleteClientIPHeaders removes every client IP header, including spellings | |
| 296 | + | // with underscores (X_Real_IP). Go keeps those under a separate key, but | |
| 297 | + | // CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable, | |
| 298 | + | // so a client could otherwise override the value set by Gordon. | |
| 299 | + | func deleteClientIPHeaders(h http.Header) { | |
| 300 | + | for key := range h { | |
| 301 | + | normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-")) | |
| 302 | + | if slices.Contains(clientIPHeaders, normalized) { | |
| 303 | + | delete(h, key) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 265 | 308 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| 266 | 309 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | |
| 267 | 310 | return func(resp *http.Response) error { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "net/http" | 10 | "net/http" | ||
| 11 | "net/http/httputil" | 11 | "net/http/httputil" | ||
| 12 | "net/url" | 12 | "net/url" | ||
| 13 | + | "slices" | |||
| 13 | "strconv" | 14 | "strconv" | ||
| 14 | "strings" | 15 | "strings" | ||
| 15 | "time" | 16 | "time" | ||
| @@ -17,6 +18,7 @@ import ( | |||||
| 17 | "github.com/bnema/zerowrap" | 18 | "github.com/bnema/zerowrap" | ||
| 18 | 19 | ||||
| 19 | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | 20 | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | ||
| 21 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 20 | "github.com/bnema/gordon/internal/domain" | 22 | "github.com/bnema/gordon/internal/domain" | ||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| @@ -235,13 +237,15 @@ func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string { | |||||
| 235 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | 237 | // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. | ||
| 236 | // | 238 | // | ||
| 237 | // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the | 239 | // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the | ||
| 238 | - | // request originates from a trusted upstream proxy. | 240 | + | // request originates from a trusted upstream proxy, and to resolve the client IP |
| 241 | + | // sent upstream in X-Real-IP and X-Forwarded-For. | |||
| 239 | func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { | 242 | func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { | ||
| 240 | return &httputil.ReverseProxy{ | 243 | return &httputil.ReverseProxy{ | ||
| 241 | Rewrite: func(pr *httputil.ProxyRequest) { | 244 | Rewrite: func(pr *httputil.ProxyRequest) { | ||
| 242 | existingProto := pr.In.Header.Get("X-Forwarded-Proto") | 245 | existingProto := pr.In.Header.Get("X-Forwarded-Proto") | ||
| 243 | pr.SetURL(opts.targetURL) | 246 | pr.SetURL(opts.targetURL) | ||
| 244 | pr.SetXForwarded() | 247 | pr.SetXForwarded() | ||
| 248 | + | setClientIPHeaders(pr, opts.trustedNets) | |||
| 245 | if opts.hostHeader != "" { | 249 | if opts.hostHeader != "" { | ||
| 246 | pr.Out.Host = opts.hostHeader | 250 | pr.Out.Host = opts.hostHeader | ||
| 247 | } | 251 | } | ||
| @@ -262,6 +266,45 @@ func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { | |||||
| 262 | } | 266 | } | ||
| 263 | } | 267 | } | ||
| 264 | 268 | ||||
| 269 | + | // clientIPHeaders are the headers carrying a client IP that Gordon rewrites | |||
| 270 | + | // before proxying to a container, in canonical form. | |||
| 271 | + | var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"} | |||
| 272 | + | ||||
| 273 | + | // setClientIPHeaders replaces client-supplied IP headers with the client IP | |||
| 274 | + | // resolved by the same trusted-proxy rules Gordon uses for its own logs and | |||
| 275 | + | // rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For. | |||
| 276 | + | // Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted | |||
| 277 | + | // Cloudflare peer. | |||
| 278 | + | func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) { | |||
| 279 | + | cfIP := pr.Out.Header.Get("Cf-Connecting-Ip") | |||
| 280 | + | deleteClientIPHeaders(pr.Out.Header) | |||
| 281 | + | ||||
| 282 | + | clientIP := middleware.GetClientIP(pr.In, trustedNets) | |||
| 283 | + | parsed := net.ParseIP(clientIP) | |||
| 284 | + | if parsed == nil { | |||
| 285 | + | // No usable peer address (e.g. a Unix socket): send no client IP at all. | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | pr.Out.Header.Set("X-Real-IP", clientIP) | |||
| 289 | + | pr.Out.Header.Set("X-Forwarded-For", clientIP) | |||
| 290 | + | if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) { | |||
| 291 | + | pr.Out.Header.Set("Cf-Connecting-Ip", clientIP) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // deleteClientIPHeaders removes every client IP header, including spellings | |||
| 296 | + | // with underscores (X_Real_IP). Go keeps those under a separate key, but | |||
| 297 | + | // CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable, | |||
| 298 | + | // so a client could otherwise override the value set by Gordon. | |||
| 299 | + | func deleteClientIPHeaders(h http.Header) { | |||
| 300 | + | for key := range h { | |||
| 301 | + | normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-")) | |||
| 302 | + | if slices.Contains(clientIPHeaders, normalized) { | |||
| 303 | + | delete(h, key) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 265 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | 308 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | ||
| 266 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | 309 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | ||
| 267 | return func(resp *http.Response) error { | 310 | return func(resp *http.Response) error { | ||
M internal/adapters/in/http/proxy/forwarder_test.go +184 -0
| @@ -370,3 +370,187 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||
| 370 | 370 | }) | |
| 371 | 371 | } | |
| 372 | 372 | } | |
| 373 | + | ||
| 374 | + | func TestNewReverseProxy_ClientIPHeaders(t *testing.T) { | |
| 375 | + | trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8", "fd00::/8", "173.245.48.0/20"}) | |
| 376 | + | ||
| 377 | + | tests := []struct { | |
| 378 | + | name string | |
| 379 | + | remoteAddr string | |
| 380 | + | headers http.Header | |
| 381 | + | nets []*net.IPNet | |
| 382 | + | wantIP string // expected X-Real-IP and X-Forwarded-For, empty = absent | |
| 383 | + | wantCF string // expected Cf-Connecting-Ip, empty = absent | |
| 384 | + | }{ | |
| 385 | + | { | |
| 386 | + | name: "untrusted IPv4 peer forged headers replaced", | |
| 387 | + | remoteAddr: "203.0.113.7:4321", | |
| 388 | + | headers: http.Header{ | |
| 389 | + | "X-Real-IP": {"1.2.3.4"}, | |
| 390 | + | "X-Forwarded-For": {"1.2.3.4"}, | |
| 391 | + | "Cf-Connecting-Ip": {"1.2.3.4"}}, | |
| 392 | + | nets: trustedNets, | |
| 393 | + | wantIP: "203.0.113.7", | |
| 394 | + | }, | |
| 395 | + | { | |
| 396 | + | name: "untrusted IPv6 peer forged X-Real-IP replaced", | |
| 397 | + | remoteAddr: "[2001:db8::7]:4321", | |
| 398 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}}, | |
| 399 | + | nets: trustedNets, | |
| 400 | + | wantIP: "2001:db8::7", | |
| 401 | + | }, | |
| 402 | + | { | |
| 403 | + | name: "untrusted IPv4-mapped IPv6 peer normalized to IPv4", | |
| 404 | + | remoteAddr: "[::ffff:203.0.113.7]:4321", | |
| 405 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}}, | |
| 406 | + | nets: trustedNets, | |
| 407 | + | wantIP: "203.0.113.7", | |
| 408 | + | }, | |
| 409 | + | { | |
| 410 | + | name: "no trusted nets ignores proxy headers", | |
| 411 | + | remoteAddr: "10.0.0.1:4321", | |
| 412 | + | headers: http.Header{"X-Forwarded-For": {"198.51.100.9"}}, | |
| 413 | + | nets: nil, | |
| 414 | + | wantIP: "10.0.0.1", | |
| 415 | + | }, | |
| 416 | + | { | |
| 417 | + | name: "trusted Cloudflare peer uses Cf-Connecting-Ip", | |
| 418 | + | remoteAddr: "173.245.48.10:4321", | |
| 419 | + | headers: http.Header{ | |
| 420 | + | "Cf-Connecting-Ip": {"198.51.100.9"}, | |
| 421 | + | "X-Real-IP": {"1.2.3.4"}, | |
| 422 | + | "X-Forwarded-For": {"1.2.3.4"}}, | |
| 423 | + | nets: trustedNets, | |
| 424 | + | wantIP: "198.51.100.9", | |
| 425 | + | wantCF: "198.51.100.9", | |
| 426 | + | }, | |
| 427 | + | { | |
| 428 | + | name: "trusted Cloudflare peer with IPv6 client", | |
| 429 | + | remoteAddr: "173.245.48.10:4321", | |
| 430 | + | headers: http.Header{"Cf-Connecting-Ip": {"2001:db8::9"}}, | |
| 431 | + | nets: trustedNets, | |
| 432 | + | wantIP: "2001:db8::9", | |
| 433 | + | wantCF: "2001:db8::9", | |
| 434 | + | }, | |
| 435 | + | { | |
| 436 | + | name: "trusted non-Cloudflare proxy uses right-most untrusted XFF hop", | |
| 437 | + | remoteAddr: "10.0.0.1:4321", | |
| 438 | + | headers: http.Header{ | |
| 439 | + | "X-Forwarded-For": {"1.2.3.4, 198.51.100.9, 10.0.0.2"}, | |
| 440 | + | "Cf-Connecting-Ip": {"1.2.3.4"}}, | |
| 441 | + | nets: trustedNets, | |
| 442 | + | wantIP: "198.51.100.9", | |
| 443 | + | }, | |
| 444 | + | { | |
| 445 | + | name: "trusted IPv6 proxy with IPv4-mapped XFF hop", | |
| 446 | + | remoteAddr: "[fd00::1]:4321", | |
| 447 | + | headers: http.Header{"X-Forwarded-For": {"::ffff:198.51.100.9"}}, | |
| 448 | + | nets: trustedNets, | |
| 449 | + | wantIP: "198.51.100.9", | |
| 450 | + | }, | |
| 451 | + | { | |
| 452 | + | name: "trusted proxy X-Real-IP honored without XFF", | |
| 453 | + | remoteAddr: "10.0.0.1:4321", | |
| 454 | + | headers: http.Header{"X-Real-IP": {"2001:db8::9"}}, | |
| 455 | + | nets: trustedNets, | |
| 456 | + | wantIP: "2001:db8::9", | |
| 457 | + | }, | |
| 458 | + | { | |
| 459 | + | name: "trusted proxy adding a new XFF line uses that line", | |
| 460 | + | remoteAddr: "10.0.0.1:4321", | |
| 461 | + | headers: http.Header{"X-Forwarded-For": {"1.2.3.4", "198.51.100.9"}}, | |
| 462 | + | nets: trustedNets, | |
| 463 | + | wantIP: "198.51.100.9", | |
| 464 | + | }, | |
| 465 | + | { | |
| 466 | + | name: "trusted proxy repeated X-Real-IP ignored", | |
| 467 | + | remoteAddr: "10.0.0.1:4321", | |
| 468 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4", "198.51.100.9"}}, | |
| 469 | + | nets: trustedNets, | |
| 470 | + | wantIP: "10.0.0.1", | |
| 471 | + | }, | |
| 472 | + | { | |
| 473 | + | name: "underscore header variants removed", | |
| 474 | + | remoteAddr: "203.0.113.7:4321", | |
| 475 | + | headers: http.Header{ | |
| 476 | + | "X_Real_IP": {"1.2.3.4"}, | |
| 477 | + | "X_Forwarded_For": {"1.2.3.4"}, | |
| 478 | + | "Cf_Connecting_Ip": {"1.2.3.4"}, | |
| 479 | + | }, | |
| 480 | + | nets: trustedNets, | |
| 481 | + | wantIP: "203.0.113.7", | |
| 482 | + | }, | |
| 483 | + | { | |
| 484 | + | name: "Cloudflare peer not in trusted proxies strips Cf-Connecting-Ip", | |
| 485 | + | remoteAddr: "173.245.48.10:4321", | |
| 486 | + | headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}}, | |
| 487 | + | nets: gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}), | |
| 488 | + | wantIP: "173.245.48.10", | |
| 489 | + | }, | |
| 490 | + | { | |
| 491 | + | name: "IPv4-mapped trusted Cloudflare peer uses Cf-Connecting-Ip", | |
| 492 | + | remoteAddr: "[::ffff:173.245.48.10]:4321", | |
| 493 | + | headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}}, | |
| 494 | + | nets: trustedNets, | |
| 495 | + | wantIP: "198.51.100.9", | |
| 496 | + | wantCF: "198.51.100.9", | |
| 497 | + | }, | |
| 498 | + | { | |
| 499 | + | name: "trusted Cloudflare peer with invalid Cf-Connecting-Ip falls back", | |
| 500 | + | remoteAddr: "173.245.48.10:4321", | |
| 501 | + | headers: http.Header{ | |
| 502 | + | "Cf-Connecting-Ip": {"not-an-ip"}, | |
| 503 | + | "X-Forwarded-For": {"198.51.100.9"}, | |
| 504 | + | }, | |
| 505 | + | nets: trustedNets, | |
| 506 | + | wantIP: "198.51.100.9", | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "unparsable peer sends no client IP", | |
| 510 | + | remoteAddr: "@", | |
| 511 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}, "X-Forwarded-For": {"1.2.3.4"}}, | |
| 512 | + | nets: trustedNets, | |
| 513 | + | }, | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | for _, tt := range tests { | |
| 517 | + | t.Run(tt.name, func(t *testing.T) { | |
| 518 | + | var got http.Header | |
| 519 | + | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 520 | + | got = r.Header.Clone() | |
| 521 | + | w.WriteHeader(http.StatusOK) | |
| 522 | + | })) | |
| 523 | + | defer backend.Close() | |
| 524 | + | ||
| 525 | + | backendURL, err := url.Parse(backend.URL) | |
| 526 | + | require.NoError(t, err) | |
| 527 | + | ||
| 528 | + | incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |
| 529 | + | incomingReq.RemoteAddr = tt.remoteAddr | |
| 530 | + | for k, v := range tt.headers { | |
| 531 | + | incomingReq.Header[http.CanonicalHeaderKey(k)] = v | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | proxy := newReverseProxy(reverseProxyOptions{ | |
| 535 | + | targetURL: backendURL, | |
| 536 | + | transport: http.DefaultTransport, | |
| 537 | + | incomingReq: incomingReq, | |
| 538 | + | trustedNets: tt.nets, | |
| 539 | + | errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 540 | + | http.Error(w, err.Error(), http.StatusBadGateway) | |
| 541 | + | }, | |
| 542 | + | }) | |
| 543 | + | ||
| 544 | + | rec := httptest.NewRecorder() | |
| 545 | + | proxy.ServeHTTP(rec, incomingReq) | |
| 546 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 547 | + | ||
| 548 | + | assert.Equal(t, tt.wantIP, got.Get("X-Real-IP")) | |
| 549 | + | assert.Equal(t, tt.wantIP, got.Get("X-Forwarded-For")) | |
| 550 | + | assert.Equal(t, tt.wantCF, got.Get("Cf-Connecting-Ip")) | |
| 551 | + | for key := range got { | |
| 552 | + | assert.NotContains(t, key, "_", "underscore header %q forwarded", key) | |
| 553 | + | } | |
| 554 | + | }) | |
| 555 | + | } | |
| 556 | + | } | |
| @@ -370,3 +370,187 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||||
| 370 | }) | 370 | }) | ||
| 371 | } | 371 | } | ||
| 372 | } | 372 | } | ||
| 373 | + | ||||
| 374 | + | func TestNewReverseProxy_ClientIPHeaders(t *testing.T) { | |||
| 375 | + | trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8", "fd00::/8", "173.245.48.0/20"}) | |||
| 376 | + | ||||
| 377 | + | tests := []struct { | |||
| 378 | + | name string | |||
| 379 | + | remoteAddr string | |||
| 380 | + | headers http.Header | |||
| 381 | + | nets []*net.IPNet | |||
| 382 | + | wantIP string // expected X-Real-IP and X-Forwarded-For, empty = absent | |||
| 383 | + | wantCF string // expected Cf-Connecting-Ip, empty = absent | |||
| 384 | + | }{ | |||
| 385 | + | { | |||
| 386 | + | name: "untrusted IPv4 peer forged headers replaced", | |||
| 387 | + | remoteAddr: "203.0.113.7:4321", | |||
| 388 | + | headers: http.Header{ | |||
| 389 | + | "X-Real-IP": {"1.2.3.4"}, | |||
| 390 | + | "X-Forwarded-For": {"1.2.3.4"}, | |||
| 391 | + | "Cf-Connecting-Ip": {"1.2.3.4"}}, | |||
| 392 | + | nets: trustedNets, | |||
| 393 | + | wantIP: "203.0.113.7", | |||
| 394 | + | }, | |||
| 395 | + | { | |||
| 396 | + | name: "untrusted IPv6 peer forged X-Real-IP replaced", | |||
| 397 | + | remoteAddr: "[2001:db8::7]:4321", | |||
| 398 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}}, | |||
| 399 | + | nets: trustedNets, | |||
| 400 | + | wantIP: "2001:db8::7", | |||
| 401 | + | }, | |||
| 402 | + | { | |||
| 403 | + | name: "untrusted IPv4-mapped IPv6 peer normalized to IPv4", | |||
| 404 | + | remoteAddr: "[::ffff:203.0.113.7]:4321", | |||
| 405 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}}, | |||
| 406 | + | nets: trustedNets, | |||
| 407 | + | wantIP: "203.0.113.7", | |||
| 408 | + | }, | |||
| 409 | + | { | |||
| 410 | + | name: "no trusted nets ignores proxy headers", | |||
| 411 | + | remoteAddr: "10.0.0.1:4321", | |||
| 412 | + | headers: http.Header{"X-Forwarded-For": {"198.51.100.9"}}, | |||
| 413 | + | nets: nil, | |||
| 414 | + | wantIP: "10.0.0.1", | |||
| 415 | + | }, | |||
| 416 | + | { | |||
| 417 | + | name: "trusted Cloudflare peer uses Cf-Connecting-Ip", | |||
| 418 | + | remoteAddr: "173.245.48.10:4321", | |||
| 419 | + | headers: http.Header{ | |||
| 420 | + | "Cf-Connecting-Ip": {"198.51.100.9"}, | |||
| 421 | + | "X-Real-IP": {"1.2.3.4"}, | |||
| 422 | + | "X-Forwarded-For": {"1.2.3.4"}}, | |||
| 423 | + | nets: trustedNets, | |||
| 424 | + | wantIP: "198.51.100.9", | |||
| 425 | + | wantCF: "198.51.100.9", | |||
| 426 | + | }, | |||
| 427 | + | { | |||
| 428 | + | name: "trusted Cloudflare peer with IPv6 client", | |||
| 429 | + | remoteAddr: "173.245.48.10:4321", | |||
| 430 | + | headers: http.Header{"Cf-Connecting-Ip": {"2001:db8::9"}}, | |||
| 431 | + | nets: trustedNets, | |||
| 432 | + | wantIP: "2001:db8::9", | |||
| 433 | + | wantCF: "2001:db8::9", | |||
| 434 | + | }, | |||
| 435 | + | { | |||
| 436 | + | name: "trusted non-Cloudflare proxy uses right-most untrusted XFF hop", | |||
| 437 | + | remoteAddr: "10.0.0.1:4321", | |||
| 438 | + | headers: http.Header{ | |||
| 439 | + | "X-Forwarded-For": {"1.2.3.4, 198.51.100.9, 10.0.0.2"}, | |||
| 440 | + | "Cf-Connecting-Ip": {"1.2.3.4"}}, | |||
| 441 | + | nets: trustedNets, | |||
| 442 | + | wantIP: "198.51.100.9", | |||
| 443 | + | }, | |||
| 444 | + | { | |||
| 445 | + | name: "trusted IPv6 proxy with IPv4-mapped XFF hop", | |||
| 446 | + | remoteAddr: "[fd00::1]:4321", | |||
| 447 | + | headers: http.Header{"X-Forwarded-For": {"::ffff:198.51.100.9"}}, | |||
| 448 | + | nets: trustedNets, | |||
| 449 | + | wantIP: "198.51.100.9", | |||
| 450 | + | }, | |||
| 451 | + | { | |||
| 452 | + | name: "trusted proxy X-Real-IP honored without XFF", | |||
| 453 | + | remoteAddr: "10.0.0.1:4321", | |||
| 454 | + | headers: http.Header{"X-Real-IP": {"2001:db8::9"}}, | |||
| 455 | + | nets: trustedNets, | |||
| 456 | + | wantIP: "2001:db8::9", | |||
| 457 | + | }, | |||
| 458 | + | { | |||
| 459 | + | name: "trusted proxy adding a new XFF line uses that line", | |||
| 460 | + | remoteAddr: "10.0.0.1:4321", | |||
| 461 | + | headers: http.Header{"X-Forwarded-For": {"1.2.3.4", "198.51.100.9"}}, | |||
| 462 | + | nets: trustedNets, | |||
| 463 | + | wantIP: "198.51.100.9", | |||
| 464 | + | }, | |||
| 465 | + | { | |||
| 466 | + | name: "trusted proxy repeated X-Real-IP ignored", | |||
| 467 | + | remoteAddr: "10.0.0.1:4321", | |||
| 468 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4", "198.51.100.9"}}, | |||
| 469 | + | nets: trustedNets, | |||
| 470 | + | wantIP: "10.0.0.1", | |||
| 471 | + | }, | |||
| 472 | + | { | |||
| 473 | + | name: "underscore header variants removed", | |||
| 474 | + | remoteAddr: "203.0.113.7:4321", | |||
| 475 | + | headers: http.Header{ | |||
| 476 | + | "X_Real_IP": {"1.2.3.4"}, | |||
| 477 | + | "X_Forwarded_For": {"1.2.3.4"}, | |||
| 478 | + | "Cf_Connecting_Ip": {"1.2.3.4"}, | |||
| 479 | + | }, | |||
| 480 | + | nets: trustedNets, | |||
| 481 | + | wantIP: "203.0.113.7", | |||
| 482 | + | }, | |||
| 483 | + | { | |||
| 484 | + | name: "Cloudflare peer not in trusted proxies strips Cf-Connecting-Ip", | |||
| 485 | + | remoteAddr: "173.245.48.10:4321", | |||
| 486 | + | headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}}, | |||
| 487 | + | nets: gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}), | |||
| 488 | + | wantIP: "173.245.48.10", | |||
| 489 | + | }, | |||
| 490 | + | { | |||
| 491 | + | name: "IPv4-mapped trusted Cloudflare peer uses Cf-Connecting-Ip", | |||
| 492 | + | remoteAddr: "[::ffff:173.245.48.10]:4321", | |||
| 493 | + | headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}}, | |||
| 494 | + | nets: trustedNets, | |||
| 495 | + | wantIP: "198.51.100.9", | |||
| 496 | + | wantCF: "198.51.100.9", | |||
| 497 | + | }, | |||
| 498 | + | { | |||
| 499 | + | name: "trusted Cloudflare peer with invalid Cf-Connecting-Ip falls back", | |||
| 500 | + | remoteAddr: "173.245.48.10:4321", | |||
| 501 | + | headers: http.Header{ | |||
| 502 | + | "Cf-Connecting-Ip": {"not-an-ip"}, | |||
| 503 | + | "X-Forwarded-For": {"198.51.100.9"}, | |||
| 504 | + | }, | |||
| 505 | + | nets: trustedNets, | |||
| 506 | + | wantIP: "198.51.100.9", | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "unparsable peer sends no client IP", | |||
| 510 | + | remoteAddr: "@", | |||
| 511 | + | headers: http.Header{"X-Real-IP": {"1.2.3.4"}, "X-Forwarded-For": {"1.2.3.4"}}, | |||
| 512 | + | nets: trustedNets, | |||
| 513 | + | }, | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | for _, tt := range tests { | |||
| 517 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 518 | + | var got http.Header | |||
| 519 | + | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 520 | + | got = r.Header.Clone() | |||
| 521 | + | w.WriteHeader(http.StatusOK) | |||
| 522 | + | })) | |||
| 523 | + | defer backend.Close() | |||
| 524 | + | ||||
| 525 | + | backendURL, err := url.Parse(backend.URL) | |||
| 526 | + | require.NoError(t, err) | |||
| 527 | + | ||||
| 528 | + | incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |||
| 529 | + | incomingReq.RemoteAddr = tt.remoteAddr | |||
| 530 | + | for k, v := range tt.headers { | |||
| 531 | + | incomingReq.Header[http.CanonicalHeaderKey(k)] = v | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | proxy := newReverseProxy(reverseProxyOptions{ | |||
| 535 | + | targetURL: backendURL, | |||
| 536 | + | transport: http.DefaultTransport, | |||
| 537 | + | incomingReq: incomingReq, | |||
| 538 | + | trustedNets: tt.nets, | |||
| 539 | + | errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { | |||
| 540 | + | http.Error(w, err.Error(), http.StatusBadGateway) | |||
| 541 | + | }, | |||
| 542 | + | }) | |||
| 543 | + | ||||
| 544 | + | rec := httptest.NewRecorder() | |||
| 545 | + | proxy.ServeHTTP(rec, incomingReq) | |||
| 546 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 547 | + | ||||
| 548 | + | assert.Equal(t, tt.wantIP, got.Get("X-Real-IP")) | |||
| 549 | + | assert.Equal(t, tt.wantIP, got.Get("X-Forwarded-For")) | |||
| 550 | + | assert.Equal(t, tt.wantCF, got.Get("Cf-Connecting-Ip")) | |||
| 551 | + | for key := range got { | |||
| 552 | + | assert.NotContains(t, key, "_", "underscore header %q forwarded", key) | |||
| 553 | + | } | |||
| 554 | + | }) | |||
| 555 | + | } | |||
| 556 | + | } | |||