diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index c8225ada..e2666e2f 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -109,6 +109,7 @@ *** xref:spring-cloud-gateway-server-webmvc/filters/requestsize.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/setrequesthostheader.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/tokenrelay.adoc[] +** xref:spring-cloud-gateway-server-webmvc/httpheadersfilters.adoc[] ** xref:spring-cloud-gateway-server-webmvc/writing-custom-predicates-and-filters.adoc[] ** xref:spring-cloud-gateway-server-webmvc/working-with-servlets-and-filters.adoc[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/httpheadersfilters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/httpheadersfilters.adoc index 49a227e1..a4a50bba 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/httpheadersfilters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/httpheadersfilters.adoc @@ -5,7 +5,7 @@ [[forwarded-headers-filter]] == Forwarded Headers Filter -The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. +The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. To activate this filter set the `spring.cloud.gateway.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. The `Forwarded by` header part can be enabled by setting the following property to true (defaults to false): @@ -29,7 +29,7 @@ To change this, set the `spring.cloud.gateway.filter.remove-hop-by-hop.headers` [[xforwarded-headers-filter]] == XForwarded Headers Filter -The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. +The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. To activate this filter set the `spring.cloud.gateway.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. Creating of individual headers can be controlled by the following boolean properties (defaults to true): diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/httpheadersfilters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/httpheadersfilters.adoc new file mode 100644 index 00000000..d60c00d7 --- /dev/null +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/httpheadersfilters.adoc @@ -0,0 +1,45 @@ +[[httpheadersfilters]] += HttpHeadersFilters + +HttpHeadersFilters are applied to the requests before sending them downstream, such as in the `NettyRoutingFilter`. + +[[forwarded-headers-filter]] +== Forwarded Headers Filter +The `Forwarded` Headers Filter creates a `Forwarded` header to send to the downstream service. It adds the `Host` header, scheme and port of the current request to any existing `Forwarded` header. To activate this filter set the `spring.cloud.gateway.mvc.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. + +[[removehopbyhop-headers-filter]] +== RemoveHopByHop Headers Filter +The `RemoveHopByHop` Headers Filter removes headers from forwarded requests. The default list of headers that is removed comes from the https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3[IETF]. + +.The default removed headers are: +* Connection +* Keep-Alive +* Proxy-Authenticate +* Proxy-Authorization +* TE +* Trailer +* Transfer-Encoding +* Upgrade + +//To change this, set the `spring.cloud.gateway.filter.remove-hop-by-hop.headers` property to the list of header names to remove. + +[[xforwarded-headers-filter]] +== XForwarded Headers Filter +The `XForwarded` Headers Filter creates various `X-Forwarded-*` headers to send to the downstream service. It uses the `Host` header, scheme, port and path of the current request to create the various headers. To activate this filter set the `spring.cloud.gateway.mvc.trusted-proxies` property to a Java Regular Expression. This regular expression defines the proxies that are trusted when they appear in the `Forwarded` header. + +Creating of individual headers can be controlled by the following boolean properties (defaults to true): + +- `spring.cloud.gateway.x-forwarded.for-enabled` +- `spring.cloud.gateway.x-forwarded.host-enabled` +- `spring.cloud.gateway.x-forwarded.port-enabled` +- `spring.cloud.gateway.x-forwarded.proto-enabled` +- `spring.cloud.gateway.x-forwarded.prefix-enabled` + +Appending multiple headers can be controlled by the following boolean properties (defaults to true): + +- `spring.cloud.gateway.x-forwarded.for-append` +- `spring.cloud.gateway.x-forwarded.host-append` +- `spring.cloud.gateway.x-forwarded.port-append` +- `spring.cloud.gateway.x-forwarded.proto-append` +- `spring.cloud.gateway.x-forwarded.prefix-append` + diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index d4660a70..13231741 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -1,9 +1,9 @@ |=== |Name | Default | Description -|spring.cloud.gateway | | +|spring.cloud.gateway | | |spring.cloud.gateway.default-filters | | List of filter definitions that are applied to every route. -|spring.cloud.gateway.discovery.locator | | +|spring.cloud.gateway.discovery.locator | | |spring.cloud.gateway.discovery.locator.enabled | `+++false+++` | Flag that enables DiscoveryClient gateway integration. |spring.cloud.gateway.discovery.locator.filters | | |spring.cloud.gateway.discovery.locator.include-expression | `+++true+++` | SpEL expression that will evaluate whether to include a service in gateway integration or not, defaults to: true. @@ -21,10 +21,10 @@ |spring.cloud.gateway.filter.fallback-headers.enabled | `+++true+++` | Enables the fallback-headers filter. |spring.cloud.gateway.filter.hystrix.enabled | `+++true+++` | Enables the hystrix filter. |spring.cloud.gateway.filter.json-to-grpc.enabled | `+++true+++` | Enables the JSON to gRPC filter. -|spring.cloud.gateway.filter.local-response-cache | | +|spring.cloud.gateway.filter.local-response-cache | | |spring.cloud.gateway.filter.local-response-cache.enabled | `+++false+++` | Enables the local-response-cache filter. -|spring.cloud.gateway.filter.local-response-cache.request | | -|spring.cloud.gateway.filter.local-response-cache.request.no-cache-strategy | `+++skip-update-cache-entry+++` | +|spring.cloud.gateway.filter.local-response-cache.request | | +|spring.cloud.gateway.filter.local-response-cache.request.no-cache-strategy | `+++skip-update-cache-entry+++` | |spring.cloud.gateway.filter.local-response-cache.size | | Maximum size of the cache to evict entries for this route (in KB, MB and GB). |spring.cloud.gateway.filter.local-response-cache.time-to-live | `+++5m+++` | Time to expire a cache entry (expressed in s for seconds, m for minutes, and h for hours). |spring.cloud.gateway.filter.map-request-header.enabled | `+++true+++` | Enables the map-request-header filter. @@ -33,16 +33,16 @@ |spring.cloud.gateway.filter.prefix-path.enabled | `+++true+++` | Enables the prefix-path filter. |spring.cloud.gateway.filter.preserve-host-header.enabled | `+++true+++` | Enables the preserve-host-header filter. |spring.cloud.gateway.filter.redirect-to.enabled | `+++true+++` | Enables the redirect-to filter. -|spring.cloud.gateway.filter.remove-hop-by-hop | | -|spring.cloud.gateway.filter.remove-hop-by-hop.headers | | +|spring.cloud.gateway.filter.remove-hop-by-hop | | +|spring.cloud.gateway.filter.remove-hop-by-hop.headers | | |spring.cloud.gateway.filter.remove-hop-by-hop.order | `+++0+++` | |spring.cloud.gateway.filter.remove-request-header.enabled | `+++true+++` | Enables the remove-request-header filter. |spring.cloud.gateway.filter.remove-request-parameter.enabled | `+++true+++` | Enables the remove-request-parameter filter. |spring.cloud.gateway.filter.remove-response-header.enabled | `+++true+++` | Enables the remove-response-header filter. |spring.cloud.gateway.filter.request-header-size.enabled | `+++true+++` | Enables the request-header-size filter. |spring.cloud.gateway.filter.request-header-to-request-uri.enabled | `+++true+++` | Enables the request-header-to-request-uri filter. -|spring.cloud.gateway.filter.request-rate-limiter | | -|spring.cloud.gateway.filter.request-rate-limiter.default-key-resolver | | +|spring.cloud.gateway.filter.request-rate-limiter | | +|spring.cloud.gateway.filter.request-rate-limiter.default-key-resolver | | |spring.cloud.gateway.filter.request-rate-limiter.default-rate-limiter | | |spring.cloud.gateway.filter.request-rate-limiter.enabled | `+++true+++` | Enables the request-rate-limiter filter. |spring.cloud.gateway.filter.request-size.enabled | `+++true+++` | Enables the request-size filter. @@ -53,18 +53,18 @@ |spring.cloud.gateway.filter.rewrite-request-parameter.enabled | `+++true+++` | Enables the rewrite-request-parameter filter. |spring.cloud.gateway.filter.rewrite-response-header.enabled | `+++true+++` | Enables the rewrite-response-header filter. |spring.cloud.gateway.filter.save-session.enabled | `+++true+++` | Enables the save-session filter. -|spring.cloud.gateway.filter.secure-headers | | -|spring.cloud.gateway.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` | +|spring.cloud.gateway.filter.secure-headers | | +|spring.cloud.gateway.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` | |spring.cloud.gateway.filter.secure-headers.content-type-options | `+++nosniff+++` | -|spring.cloud.gateway.filter.secure-headers.default-headers | | -|spring.cloud.gateway.filter.secure-headers.disable | | -|spring.cloud.gateway.filter.secure-headers.disabled-headers | | -|spring.cloud.gateway.filter.secure-headers.download-options | `+++noopen+++` | +|spring.cloud.gateway.filter.secure-headers.default-headers | | +|spring.cloud.gateway.filter.secure-headers.disable | | +|spring.cloud.gateway.filter.secure-headers.disabled-headers | | +|spring.cloud.gateway.filter.secure-headers.download-options | `+++noopen+++` | |spring.cloud.gateway.filter.secure-headers.enabled | `+++true+++` | Enables the secure-headers filter. -|spring.cloud.gateway.filter.secure-headers.enabled-headers | | -|spring.cloud.gateway.filter.secure-headers.frame-options | `+++DENY+++` | -|spring.cloud.gateway.filter.secure-headers.permissions-policy | `+++accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()+++` | -|spring.cloud.gateway.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` | +|spring.cloud.gateway.filter.secure-headers.enabled-headers | | +|spring.cloud.gateway.filter.secure-headers.frame-options | `+++DENY+++` | +|spring.cloud.gateway.filter.secure-headers.permissions-policy | `+++accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()+++` | +|spring.cloud.gateway.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` | |spring.cloud.gateway.filter.secure-headers.referrer-policy | `+++no-referrer+++` | |spring.cloud.gateway.filter.secure-headers.strict-transport-security | `+++max-age=631138519+++` | |spring.cloud.gateway.filter.secure-headers.xss-protection-header | `+++1 ; mode=block+++` | @@ -87,16 +87,16 @@ |spring.cloud.gateway.global-filter.remove-cached-body.enabled | `+++true+++` | Enables the remove-cached-body global filter. |spring.cloud.gateway.global-filter.route-to-request-url.enabled | `+++true+++` | Enables the route-to-request-url global filter. |spring.cloud.gateway.global-filter.websocket-routing.enabled | `+++true+++` | Enables the websocket-routing global filter. -|spring.cloud.gateway.globalcors | | +|spring.cloud.gateway.globalcors | | |spring.cloud.gateway.globalcors.add-to-simple-url-handler-mapping | `+++false+++` | If global CORS config should be added to the URL handler. |spring.cloud.gateway.globalcors.cors-configurations | | |spring.cloud.gateway.handler-mapping.order | `+++1+++` | The order of RoutePredicateHandlerMapping. -|spring.cloud.gateway.httpclient | | +|spring.cloud.gateway.httpclient | | |spring.cloud.gateway.httpclient.compression | `+++false+++` | Enables compression for Netty HttpClient. |spring.cloud.gateway.httpclient.connect-timeout | | The connect timeout in millis, the default is 30s. |spring.cloud.gateway.httpclient.max-header-size | | The max response header size. |spring.cloud.gateway.httpclient.max-initial-line-length | | The max initial line length. -|spring.cloud.gateway.httpclient.pool | | +|spring.cloud.gateway.httpclient.pool | | |spring.cloud.gateway.httpclient.pool.acquire-timeout | | Only for type FIXED, the maximum time in millis to wait for acquiring. |spring.cloud.gateway.httpclient.pool.eviction-interval | `+++0+++` | Perform regular eviction checks in the background at a specified interval. Disabled by default ({@link Duration#ZERO}) |spring.cloud.gateway.httpclient.pool.leasing-strategy | `+++fifo+++` | Configures the leasing strategy for the pool (fifo or lifo), defaults to FIFO which is Netty's default. @@ -106,7 +106,7 @@ |spring.cloud.gateway.httpclient.pool.metrics | `+++false+++` | Enables channel pools metrics to be collected and registered in Micrometer. Disabled by default. |spring.cloud.gateway.httpclient.pool.name | `+++proxy+++` | The channel pool map name, defaults to proxy. |spring.cloud.gateway.httpclient.pool.type | `+++elastic+++` | Type of pool for HttpClient to use (elastic, fixed or disabled). -|spring.cloud.gateway.httpclient.proxy | | +|spring.cloud.gateway.httpclient.proxy | | |spring.cloud.gateway.httpclient.proxy.host | | Hostname for proxy configuration of Netty HttpClient. |spring.cloud.gateway.httpclient.proxy.non-proxy-hosts-pattern | | Regular expression (Java) for a configured list of hosts. that should be reached directly, bypassing the proxy |spring.cloud.gateway.httpclient.proxy.password | | Password for proxy configuration of Netty HttpClient. @@ -114,7 +114,7 @@ |spring.cloud.gateway.httpclient.proxy.type | `+++http+++` | proxyType for proxy configuration of Netty HttpClient (http, socks4 or socks5). |spring.cloud.gateway.httpclient.proxy.username | | Username for proxy configuration of Netty HttpClient. |spring.cloud.gateway.httpclient.response-timeout | | The response timeout. -|spring.cloud.gateway.httpclient.ssl | | +|spring.cloud.gateway.httpclient.ssl | | |spring.cloud.gateway.httpclient.ssl.close-notify-flush-timeout | `+++3000ms+++` | SSL close_notify flush timeout. Default to 3000 ms. |spring.cloud.gateway.httpclient.ssl.close-notify-read-timeout | `+++0+++` | SSL close_notify read timeout. Default to 0 ms. |spring.cloud.gateway.httpclient.ssl.handshake-timeout | `+++10000ms+++` | SSL handshake timeout. Default to 10000 ms @@ -126,14 +126,14 @@ |spring.cloud.gateway.httpclient.ssl.ssl-bundle | | The name of the SSL bundle to use. |spring.cloud.gateway.httpclient.ssl.trusted-x509-certificates | | Trusted certificates for verifying the remote endpoint's certificate. |spring.cloud.gateway.httpclient.ssl.use-insecure-trust-manager | `+++false+++` | Installs the netty InsecureTrustManagerFactory. This is insecure and not suitable for production. -|spring.cloud.gateway.httpclient.websocket | | +|spring.cloud.gateway.httpclient.websocket | | |spring.cloud.gateway.httpclient.websocket.max-frame-payload-length | | Max frame payload length. |spring.cloud.gateway.httpclient.websocket.proxy-ping | `+++true+++` | Proxy ping frames to downstream services, defaults to true. |spring.cloud.gateway.httpclient.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpClient. |spring.cloud.gateway.httpserver.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpServer. -|spring.cloud.gateway.loadbalancer | | -|spring.cloud.gateway.loadbalancer.use404 | `+++false+++` | -|spring.cloud.gateway.metrics | | +|spring.cloud.gateway.loadbalancer | | +|spring.cloud.gateway.loadbalancer.use404 | `+++false+++` | +|spring.cloud.gateway.metrics | | |spring.cloud.gateway.metrics.enabled | `+++false+++` | Enables the collection of metrics data. |spring.cloud.gateway.metrics.prefix | `+++spring.cloud.gateway+++` | The prefix of all metrics emitted by gateway. |spring.cloud.gateway.metrics.tags | | Tags map that added to metrics. @@ -152,6 +152,7 @@ |spring.cloud.gateway.mvc.streaming-buffer-size | `+++16384+++` | Buffer size for streaming media mime-types. |spring.cloud.gateway.mvc.streaming-media-types | | Mime-types that are streaming. |spring.cloud.gateway.mvc.transfer-encoding-normalization-request-headers-filter.enabled | `+++true+++` | Enables the transfer-encoding-normalization-request-headers-filter. +|spring.cloud.gateway.mvc.trusted-proxies | | Regular expression defining proxies that are trusted when they appear in a Forwarded of X-Forwarded header. |spring.cloud.gateway.mvc.weight-calculator-filter.enabled | `+++true+++` | Enables the weight-calculator-filter. |spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled. |spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.for-append | `+++true+++` | If appending X-Forwarded-For as a list is enabled. @@ -181,7 +182,7 @@ |spring.cloud.gateway.predicate.remote-addr.enabled | `+++true+++` | Enables the remote-addr predicate. |spring.cloud.gateway.predicate.weight.enabled | `+++true+++` | Enables the weight predicate. |spring.cloud.gateway.predicate.xforwarded-remote-addr.enabled | `+++true+++` | Enables the xforwarded-remote-addr predicate. -|spring.cloud.gateway.redis-rate-limiter | | +|spring.cloud.gateway.redis-rate-limiter | | |spring.cloud.gateway.redis-rate-limiter.burst-capacity-header | `+++X-RateLimit-Burst-Capacity+++` | The name of the header that returns the burst capacity configuration. |spring.cloud.gateway.redis-rate-limiter.config | | |spring.cloud.gateway.redis-rate-limiter.include-headers | `+++true+++` | Whether or not to include headers containing rate limiter information, defaults to true. @@ -195,10 +196,10 @@ |spring.cloud.gateway.routes | | List of Routes. |spring.cloud.gateway.server.webflux.default-filters | | List of filter definitions that are applied to every route. |spring.cloud.gateway.server.webflux.discovery.locator.enabled | `+++false+++` | Flag that enables DiscoveryClient gateway integration. -|spring.cloud.gateway.server.webflux.discovery.locator.filters | | +|spring.cloud.gateway.server.webflux.discovery.locator.filters | | |spring.cloud.gateway.server.webflux.discovery.locator.include-expression | `+++true+++` | SpEL expression that will evaluate whether to include a service in gateway integration or not, defaults to: true. |spring.cloud.gateway.server.webflux.discovery.locator.lower-case-service-id | `+++false+++` | Option to lower case serviceId in predicates and filters, defaults to false. Useful with eureka when it automatically uppercases serviceId. so MYSERIVCE, would match /myservice/** -|spring.cloud.gateway.server.webflux.discovery.locator.predicates | | +|spring.cloud.gateway.server.webflux.discovery.locator.predicates | | |spring.cloud.gateway.server.webflux.discovery.locator.route-id-prefix | | The prefix for the routeId, defaults to discoveryClient.getClass().getSimpleName() + "_". Service Id will be appended to create the routeId. |spring.cloud.gateway.server.webflux.discovery.locator.url-expression | `+++'lb://'+serviceId+++` | SpEL expression that create the uri for each route, defaults to: 'lb://'+serviceId. |spring.cloud.gateway.server.webflux.enabled | `+++true+++` | Enables gateway functionality. @@ -212,7 +213,7 @@ |spring.cloud.gateway.server.webflux.filter.hystrix.enabled | `+++true+++` | Enables the hystrix filter. |spring.cloud.gateway.server.webflux.filter.json-to-grpc.enabled | `+++true+++` | Enables the JSON to gRPC filter. |spring.cloud.gateway.server.webflux.filter.local-response-cache.enabled | `+++false+++` | Enables the local-response-cache filter. -|spring.cloud.gateway.server.webflux.filter.local-response-cache.request.no-cache-strategy | `+++skip-update-cache-entry+++` | +|spring.cloud.gateway.server.webflux.filter.local-response-cache.request.no-cache-strategy | `+++skip-update-cache-entry+++` | |spring.cloud.gateway.server.webflux.filter.local-response-cache.size | | Maximum size of the cache to evict entries for this route (in KB, MB and GB). |spring.cloud.gateway.server.webflux.filter.local-response-cache.time-to-live | `+++5m+++` | Time to expire a cache entry (expressed in s for seconds, m for minutes, and h for hours). |spring.cloud.gateway.server.webflux.filter.map-request-header.enabled | `+++true+++` | Enables the map-request-header filter. @@ -221,15 +222,15 @@ |spring.cloud.gateway.server.webflux.filter.prefix-path.enabled | `+++true+++` | Enables the prefix-path filter. |spring.cloud.gateway.server.webflux.filter.preserve-host-header.enabled | `+++true+++` | Enables the preserve-host-header filter. |spring.cloud.gateway.server.webflux.filter.redirect-to.enabled | `+++true+++` | Enables the redirect-to filter. -|spring.cloud.gateway.server.webflux.filter.remove-hop-by-hop.headers | | -|spring.cloud.gateway.server.webflux.filter.remove-hop-by-hop.order | `+++0+++` | +|spring.cloud.gateway.server.webflux.filter.remove-hop-by-hop.headers | | +|spring.cloud.gateway.server.webflux.filter.remove-hop-by-hop.order | `+++0+++` | |spring.cloud.gateway.server.webflux.filter.remove-request-header.enabled | `+++true+++` | Enables the remove-request-header filter. |spring.cloud.gateway.server.webflux.filter.remove-request-parameter.enabled | `+++true+++` | Enables the remove-request-parameter filter. |spring.cloud.gateway.server.webflux.filter.remove-response-header.enabled | `+++true+++` | Enables the remove-response-header filter. |spring.cloud.gateway.server.webflux.filter.request-header-size.enabled | `+++true+++` | Enables the request-header-size filter. |spring.cloud.gateway.server.webflux.filter.request-header-to-request-uri.enabled | `+++true+++` | Enables the request-header-to-request-uri filter. -|spring.cloud.gateway.server.webflux.filter.request-rate-limiter.default-key-resolver | | -|spring.cloud.gateway.server.webflux.filter.request-rate-limiter.default-rate-limiter | | +|spring.cloud.gateway.server.webflux.filter.request-rate-limiter.default-key-resolver | | +|spring.cloud.gateway.server.webflux.filter.request-rate-limiter.default-rate-limiter | | |spring.cloud.gateway.server.webflux.filter.request-rate-limiter.enabled | `+++true+++` | Enables the request-rate-limiter filter. |spring.cloud.gateway.server.webflux.filter.request-size.enabled | `+++true+++` | Enables the request-size filter. |spring.cloud.gateway.server.webflux.filter.retry.enabled | `+++true+++` | Enables the retry filter. @@ -239,20 +240,20 @@ |spring.cloud.gateway.server.webflux.filter.rewrite-request-parameter.enabled | `+++true+++` | Enables the rewrite-request-parameter filter. |spring.cloud.gateway.server.webflux.filter.rewrite-response-header.enabled | `+++true+++` | Enables the rewrite-response-header filter. |spring.cloud.gateway.server.webflux.filter.save-session.enabled | `+++true+++` | Enables the save-session filter. -|spring.cloud.gateway.server.webflux.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.content-type-options | `+++nosniff+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.default-headers | | -|spring.cloud.gateway.server.webflux.filter.secure-headers.disable | | -|spring.cloud.gateway.server.webflux.filter.secure-headers.disabled-headers | | -|spring.cloud.gateway.server.webflux.filter.secure-headers.download-options | `+++noopen+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.content-type-options | `+++nosniff+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.default-headers | | +|spring.cloud.gateway.server.webflux.filter.secure-headers.disable | | +|spring.cloud.gateway.server.webflux.filter.secure-headers.disabled-headers | | +|spring.cloud.gateway.server.webflux.filter.secure-headers.download-options | `+++noopen+++` | |spring.cloud.gateway.server.webflux.filter.secure-headers.enabled | `+++true+++` | Enables the secure-headers filter. -|spring.cloud.gateway.server.webflux.filter.secure-headers.enabled-headers | | -|spring.cloud.gateway.server.webflux.filter.secure-headers.frame-options | `+++DENY+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.permissions-policy | `+++accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.referrer-policy | `+++no-referrer+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.strict-transport-security | `+++max-age=631138519+++` | -|spring.cloud.gateway.server.webflux.filter.secure-headers.xss-protection-header | `+++1 ; mode=block+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.enabled-headers | | +|spring.cloud.gateway.server.webflux.filter.secure-headers.frame-options | `+++DENY+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.permissions-policy | `+++accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.permitted-cross-domain-policies | `+++none+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.referrer-policy | `+++no-referrer+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.strict-transport-security | `+++max-age=631138519+++` | +|spring.cloud.gateway.server.webflux.filter.secure-headers.xss-protection-header | `+++1 ; mode=block+++` | |spring.cloud.gateway.server.webflux.filter.set-path.enabled | `+++true+++` | Enables the set-path filter. |spring.cloud.gateway.server.webflux.filter.set-request-header.enabled | `+++true+++` | Enables the set-request-header filter. |spring.cloud.gateway.server.webflux.filter.set-request-host-header.enabled | `+++true+++` | Enables the set-request-host-header filter. @@ -273,7 +274,7 @@ |spring.cloud.gateway.server.webflux.global-filter.route-to-request-url.enabled | `+++true+++` | Enables the route-to-request-url global filter. |spring.cloud.gateway.server.webflux.global-filter.websocket-routing.enabled | `+++true+++` | Enables the websocket-routing global filter. |spring.cloud.gateway.server.webflux.globalcors.add-to-simple-url-handler-mapping | `+++false+++` | If global CORS config should be added to the URL handler. -|spring.cloud.gateway.server.webflux.globalcors.cors-configurations | | +|spring.cloud.gateway.server.webflux.globalcors.cors-configurations | | |spring.cloud.gateway.server.webflux.handler-mapping.order | `+++1+++` | The order of RoutePredicateHandlerMapping. |spring.cloud.gateway.server.webflux.httpclient.compression | `+++false+++` | Enables compression for Netty HttpClient. |spring.cloud.gateway.server.webflux.httpclient.connect-timeout | | The connect timeout in millis, the default is 30s. @@ -310,7 +311,7 @@ |spring.cloud.gateway.server.webflux.httpclient.websocket.proxy-ping | `+++true+++` | Proxy ping frames to downstream services, defaults to true. |spring.cloud.gateway.server.webflux.httpclient.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpClient. |spring.cloud.gateway.server.webflux.httpserver.wiretap | `+++false+++` | Enables wiretap debugging for Netty HttpServer. -|spring.cloud.gateway.server.webflux.loadbalancer.use404 | `+++false+++` | +|spring.cloud.gateway.server.webflux.loadbalancer.use404 | `+++false+++` | |spring.cloud.gateway.server.webflux.metrics.enabled | `+++false+++` | Enables the collection of metrics data. |spring.cloud.gateway.server.webflux.metrics.prefix | `+++spring.cloud.gateway+++` | The prefix of all metrics emitted by gateway. |spring.cloud.gateway.server.webflux.metrics.tags | | Tags map that added to metrics. @@ -331,7 +332,7 @@ |spring.cloud.gateway.server.webflux.predicate.weight.enabled | `+++true+++` | Enables the weight predicate. |spring.cloud.gateway.server.webflux.predicate.xforwarded-remote-addr.enabled | `+++true+++` | Enables the xforwarded-remote-addr predicate. |spring.cloud.gateway.server.webflux.redis-rate-limiter.burst-capacity-header | `+++X-RateLimit-Burst-Capacity+++` | The name of the header that returns the burst capacity configuration. -|spring.cloud.gateway.server.webflux.redis-rate-limiter.config | | +|spring.cloud.gateway.server.webflux.redis-rate-limiter.config | | |spring.cloud.gateway.server.webflux.redis-rate-limiter.include-headers | `+++true+++` | Whether or not to include headers containing rate limiter information, defaults to true. |spring.cloud.gateway.server.webflux.redis-rate-limiter.remaining-header | `+++X-RateLimit-Remaining+++` | The name of the header that returns number of remaining requests during the current second. |spring.cloud.gateway.server.webflux.redis-rate-limiter.replenish-rate-header | `+++X-RateLimit-Replenish-Rate+++` | The name of the header that returns the replenish rate configuration. @@ -342,7 +343,7 @@ |spring.cloud.gateway.server.webflux.route-refresh-listener.enabled | `+++true+++` | If RouteRefreshListener should be turned on. |spring.cloud.gateway.server.webflux.routes | | List of Routes. |spring.cloud.gateway.server.webflux.set-status.original-status-header-name | | The name of the header which contains http code of the proxied request. -|spring.cloud.gateway.server.webflux.streaming-media-types | | +|spring.cloud.gateway.server.webflux.streaming-media-types | | |spring.cloud.gateway.server.webflux.x-forwarded.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled. |spring.cloud.gateway.server.webflux.x-forwarded.for-append | `+++true+++` | If appending X-Forwarded-For as a list is enabled. |spring.cloud.gateway.server.webflux.x-forwarded.for-enabled | `+++true+++` | If X-Forwarded-For is enabled. @@ -383,10 +384,10 @@ |spring.cloud.gateway.server.webmvc.x-forwarded-request-headers-filter.prefix-enabled | `+++true+++` | If X-Forwarded-Prefix is enabled. |spring.cloud.gateway.server.webmvc.x-forwarded-request-headers-filter.proto-append | `+++true+++` | If appending X-Forwarded-Proto as a list is enabled. |spring.cloud.gateway.server.webmvc.x-forwarded-request-headers-filter.proto-enabled | `+++true+++` | If X-Forwarded-Proto is enabled. -|spring.cloud.gateway.set-status | | +|spring.cloud.gateway.set-status | | |spring.cloud.gateway.set-status.original-status-header-name | | The name of the header which contains http code of the proxied request. |spring.cloud.gateway.streaming-media-types | | -|spring.cloud.gateway.x-forwarded | | +|spring.cloud.gateway.trusted-proxies | | Regular expression defining proxies that are trusted when they appear in a Forwarded or X-Forwarded header. |spring.cloud.gateway.x-forwarded.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled. |spring.cloud.gateway.x-forwarded.for-append | `+++true+++` | If appending X-Forwarded-For as a list is enabled. |spring.cloud.gateway.x-forwarded.for-enabled | `+++true+++` | If X-Forwarded-For is enabled. diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java index cc69fd06..124c3c9b 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java @@ -47,6 +47,7 @@ import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopRequest import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopResponseHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.RemoveHttp2StatusResponseHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.TransferEncodingNormalizationRequestHeadersFilter; +import org.springframework.cloud.gateway.server.mvc.filter.TrustedProxies; import org.springframework.cloud.gateway.server.mvc.filter.WeightCalculatorFilter; import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter; import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilterProperties; @@ -59,6 +60,7 @@ import org.springframework.cloud.gateway.server.mvc.predicate.PredicateBeanFacto import org.springframework.cloud.gateway.server.mvc.predicate.PredicateDiscoverer; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Import; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.Environment; @@ -120,10 +122,9 @@ public class GatewayServerMvcAutoConfiguration { @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = GatewayMvcProperties.PREFIX, name = "forwarded-request-headers-filter.enabled", - matchIfMissing = true) - public ForwardedRequestHeadersFilter forwardedRequestHeadersFilter() { - return new ForwardedRequestHeadersFilter(); + @Conditional(TrustedProxies.ForwardedTrustedProxiesCondition.class) + public ForwardedRequestHeadersFilter forwardedRequestHeadersFilter(GatewayMvcProperties properties) { + return new ForwardedRequestHeadersFilter(properties.getTrustedProxies()); } @Bean @@ -197,9 +198,10 @@ public class GatewayServerMvcAutoConfiguration { @ConditionalOnMissingBean @ConditionalOnProperty(prefix = XForwardedRequestHeadersFilterProperties.PREFIX, name = ".enabled", matchIfMissing = true) - public XForwardedRequestHeadersFilter xForwardedRequestHeadersFilter( - XForwardedRequestHeadersFilterProperties props) { - return new XForwardedRequestHeadersFilter(props); + @Conditional(TrustedProxies.XForwardedTrustedProxiesCondition.class) + public XForwardedRequestHeadersFilter xForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, + GatewayMvcProperties gatewayMvcProperties) { + return new XForwardedRequestHeadersFilter(props, gatewayMvcProperties.getTrustedProxies()); } @Bean diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java index fac05cb0..6a8d3d96 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java @@ -66,6 +66,12 @@ public class GatewayMvcProperties { */ private int streamingBufferSize = 16384; + /** + * Regular expression defining proxies that are trusted when they appear in a + * Forwarded of X-Forwarded header. + */ + private String trustedProxies; + public List getRoutes() { return routes; } @@ -102,6 +108,14 @@ public class GatewayMvcProperties { this.streamingBufferSize = streamingBufferSize; } + public String getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(String trustedProxies) { + this.trustedProxies = trustedProxies; + } + @Override public String toString() { return new ToStringCreator(this).append("httpClient", httpClient) @@ -109,6 +123,7 @@ public class GatewayMvcProperties { .append("routesMap", routesMap) .append("streamingMediaTypes", streamingMediaTypes) .append("streamingBufferSize", streamingBufferSize) + .append("trustedProxies", trustedProxies) .toString(); } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcRuntimeHintsProcessor.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcRuntimeHintsProcessor.java index 4691a19f..7635eb40 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcRuntimeHintsProcessor.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcRuntimeHintsProcessor.java @@ -29,6 +29,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.ReflectionHints; import org.springframework.aot.hint.TypeReference; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; import org.springframework.beans.factory.config.BeanDefinition; @@ -86,7 +87,7 @@ public class GatewayMvcRuntimeHintsProcessor implements BeanFactoryInitializatio private static Set> getTypesToRegister(String packageName) { Set> classesToAdd = new HashSet<>(); - ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false); + ClassPathScanningCandidateComponentProvider provider = buildProvider(); provider.addIncludeFilter(new AssignableTypeFilter(Object.class)); provider.addExcludeFilter(new AssignableTypeFilter(FilterAutoConfiguration.class)); provider.addExcludeFilter(new AssignableTypeFilter(PredicateAutoConfiguration.class)); @@ -108,6 +109,17 @@ public class GatewayMvcRuntimeHintsProcessor implements BeanFactoryInitializatio return classesToAdd; } + private static ClassPathScanningCandidateComponentProvider buildProvider() { + return new ClassPathScanningCandidateComponentProvider(false) { + @SuppressWarnings("NullableProblems") + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + // Include both concrete classes and interfaces + return beanDefinition.getMetadata().isIndependent() && !beanDefinition.getMetadata().isAnnotation(); + } + }; + } + private static boolean shouldRegisterClass(Class clazz) { Set conditionClasses = beansConditionalOnClasses.getOrDefault(clazz.getName(), Collections.emptySet()); for (String conditionClass : conditionClasses) { diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java index e4fedaf9..bb085b1f 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java @@ -24,7 +24,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedCaseInsensitiveMap; @@ -34,11 +39,26 @@ import org.springframework.web.servlet.function.ServerRequest; public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestHttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(ForwardedRequestHeadersFilter.class); + /** * Forwarded header. */ public static final String FORWARDED_HEADER = "Forwarded"; + private final TrustedProxies trustedProxies; + + @Deprecated + public ForwardedRequestHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayMvcProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public ForwardedRequestHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + /* for testing */ static List parse(List values) { ArrayList forwardeds = new ArrayList<>(); @@ -46,8 +66,11 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH return forwardeds; } for (String value : values) { - Forwarded forwarded = parse(value); - forwardeds.add(forwarded); + String[] forwardedValues = StringUtils.tokenizeToStringArray(value, ","); + for (String forwardedValue : forwardedValues) { + Forwarded forwarded = parse(forwardedValue); + forwardeds.add(forwarded); + } } return forwardeds; } @@ -89,6 +112,13 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH @Override public HttpHeaders apply(HttpHeaders input, ServerRequest request) { + if (request.servletRequest().getRemoteAddr() != null + && !trustedProxies.isTrusted(request.servletRequest().getRemoteAddr())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.servletRequest().getRemoteHost())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -102,7 +132,10 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH List forwardeds = parse(original.get(FORWARDED_HEADER)); for (Forwarded f : forwardeds) { - updated.add(FORWARDED_HEADER, f.toHeaderValue()); + // only add if "for" value matches trustedProxies + if (trustedProxies.isTrusted(f.get("for"))) { + updated.add(FORWARDED_HEADER, f.toHeaderValue()); + } } // TODO: add new forwarded @@ -124,6 +157,10 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH forValue = "[" + forValue + "]"; } } + if (!trustedProxies.isTrusted(forValue)) { + // don't add for value + return; + } int port = remoteAddress.getPort(); if (port >= 0) { forValue = forValue + ":" + port; diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java new file mode 100644 index 00000000..2489f50d --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/TrustedProxies.java @@ -0,0 +1,120 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.NoSuchElementException; +import java.util.regex.Pattern; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@FunctionalInterface +public interface TrustedProxies { + + boolean isTrusted(String host); + + static TrustedProxies from(@NonNull String trustedProxies) { + Assert.hasText(trustedProxies, "trustedProxies must not be empty"); + Pattern pattern = Pattern.compile(trustedProxies); + return value -> pattern.matcher(value).matches(); + } + + class ForwardedTrustedProxiesCondition extends AllNestedConditions { + + public ForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayMvcProperties.PREFIX + ".forwarded-request-headers-filter.enabled", + matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists(GatewayMvcProperties.PREFIX + ".trusted-proxies") + static class OnTrustedProxiesNotEmpty { + + } + + } + + class XForwardedTrustedProxiesCondition extends AllNestedConditions { + + public XForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = XForwardedRequestHeadersFilterProperties.PREFIX + ".enabled", + matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists(GatewayMvcProperties.PREFIX + ".trusted-proxies") + static class OnTrustedProxiesNotEmpty { + + } + + } + + class OnPropertyExistsCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + try { + String value = metadata.getAnnotations().get(ConditionalOnPropertyExists.class).getString("value"); + String property = context.getEnvironment().getProperty(value); + if (!StringUtils.hasText(property)) { + return ConditionOutcome.noMatch(value + " property is not set or is empty."); + } + return ConditionOutcome.match(value + " property is not empty."); + } + catch (NoSuchElementException e) { + return ConditionOutcome.noMatch("Missing required property 'value' of @ConditionalOnPropertyExists"); + } + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @Documented + @Conditional(OnPropertyExistsCondition.class) + @interface ConditionalOnPropertyExists { + + /** + * @return the property + */ + String value(); + + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java index da95fa4b..8ebb7f77 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java @@ -16,21 +16,29 @@ package org.springframework.cloud.gateway.server.mvc.filter; -import java.net.InetSocketAddress; import java.net.URI; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.web.servlet.function.ServerRequest; public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestHttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(XForwardedRequestHeadersFilter.class); + /** Default http port. */ public static final int HTTP_PORT = 80; @@ -60,8 +68,24 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request private final XForwardedRequestHeadersFilterProperties properties; + private final TrustedProxies trustedProxies; + + @Deprecated public XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties properties) { - this.properties = properties; + this(properties, s -> true); + log.warn(GatewayMvcProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to X-Forwarded header."); + } + + public XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, String trustedProxies) { + this(props, TrustedProxies.from(trustedProxies)); + } + + private XForwardedRequestHeadersFilter(XForwardedRequestHeadersFilterProperties props, + TrustedProxies trustedProxies) { + Assert.notNull(trustedProxies, "trustedProxies must not be null"); + + this.trustedProxies = trustedProxies; } @Override @@ -71,6 +95,13 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request @Override public HttpHeaders apply(HttpHeaders input, ServerRequest request) { + if (request.servletRequest().getRemoteAddr() != null + && !trustedProxies.isTrusted(request.servletRequest().getRemoteAddr())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.servletRequest().getRemoteHost())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -78,10 +109,12 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request updated.addAll(entry.getKey(), entry.getValue()); } - InetSocketAddress remoteAddress = request.remoteAddress().orElse(null); - if (properties.isForEnabled() && remoteAddress != null && remoteAddress.getAddress() != null) { - String remoteAddr = remoteAddress.getAddress().getHostAddress(); - write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, properties.isForAppend()); + if (properties.isForEnabled()) { + String remoteAddr = null; + if (request.servletRequest().getRemoteAddr() != null) { + remoteAddr = request.servletRequest().getRemoteAddr(); + } + write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, properties.isForAppend(), trustedProxies::isTrusted); } String proto = request.uri().getScheme(); @@ -156,17 +189,22 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request } private void write(HttpHeaders headers, String name, String value, boolean append) { - if (value == null) { - return; - } + write(headers, name, value, append, s -> true); + } + + private void write(HttpHeaders headers, String name, String value, boolean append, Predicate shouldWrite) { if (append) { - headers.add(name, value); + if (value != null) { + headers.add(name, value); + } // these headers should be treated as a single comma separated header - List values = headers.get(name); - String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); - headers.set(name, delimitedValue); + if (headers.containsKey(name)) { + List values = headers.get(name).stream().filter(shouldWrite).toList(); + String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); + headers.set(name, delimitedValue); + } } - else { + else if (value != null && shouldWrite.test(value)) { headers.set(name, value); } } @@ -175,11 +213,6 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request return HTTPS_SCHEME.equals(scheme) ? HTTPS_PORT : HTTP_PORT; } - private boolean hasHeader(ServerRequest request, String name) { - HttpHeaders headers = request.headers().asHttpHeaders(); - return headers.containsKey(name) && StringUtils.hasLength(headers.getFirst(name)); - } - private String toHostHeader(ServerRequest request) { int port = request.uri().getPort(); String host = request.uri().getHost(); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index 4efa9b8c..3b731d2d 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -588,7 +588,7 @@ public class ServerMvcIntegrationTests { .isOk(); } - public static final MediaType FORM_URL_ENCODED_CONTENT_TYPE = new MediaType(APPLICATION_FORM_URLENCODED, + private static final MediaType FORM_URL_ENCODED_CONTENT_TYPE = new MediaType(APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8); @Test diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java index 4da36cdc..b5174e55 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilterTests.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; @@ -61,7 +62,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -81,28 +82,39 @@ public class ForwardedRequestHeadersFilterTests { public void forwardedHeaderExists() { MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") .remoteAddress("10.0.0.1:80") - .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https; for=23.45.67.89") + .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89") .buildRequest(null); servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); - assertThat(headers.get(FORWARDED_HEADER)).hasSize(2); + assertThat(headers.get(FORWARDED_HEADER)).hasSize(3); List forwardeds = ForwardedRequestHeadersFilter.parse(headers.get(FORWARDED_HEADER)); - assertThat(forwardeds).hasSize(2); - Forwarded addedForwardedHeader = forwardeds.get(0); - Forwarded existingForwardedHeader = forwardeds.get(1); - - assertThat(existingForwardedHeader.getValues()).containsEntry("proto", "http") - .containsEntry("for", "\"10.0.0.1:80\""); - - assertThat(addedForwardedHeader.getValues()).containsEntry("proto", "https") - .containsEntry("for", "23.45.67.89"); + assertThat(forwardeds).hasSize(3); + Optional added = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").contains("10.0.0.1:80")) + .findFirst(); + assertThat(added).isPresent(); + added.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "http").containsEntry("for", "\"10.0.0.1:80\""); + }); + Optional existing = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").equals("23.45.67.89")) + .findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("for", "23.45.67.89"); + }); + existing = forwardeds.stream().filter(forwarded -> forwarded.get("for").equals("12.34.56.78")).findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "https").containsEntry("for", "12.34.56.78"); + }); } @Test @@ -113,7 +125,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("10.0.0.1"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -136,7 +148,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("2001:db8:cafe:0:0:0:0:17"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -158,7 +170,7 @@ public class ForwardedRequestHeadersFilterTests { servletRequest.setRemoteHost("unresolvable-hostname"); ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); - ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(); + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter(".*"); HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); @@ -211,4 +223,62 @@ public class ForwardedRequestHeadersFilterTests { } } + @Test + public void forwardedHeadersNotTrusted() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(FORWARDED_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedForwardedForNotAppended() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=\"127.0.0.1:80\",for=10.0.0.11") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + filtered = forwardedHeaders.stream().filter(value -> value.contains("10.0.0.11")).findFirst(); + assertThat(filtered).isNotEmpty(); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=127.0.0.1") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + ForwardedRequestHeadersFilter filter = new ForwardedRequestHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + } + } diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java new file mode 100644 index 00000000..f9c27a91 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilterTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.util.Collections; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.cloud.gateway.server.mvc.GatewayServerMvcAutoConfiguration; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.servlet.function.ServerRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_FOR_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_HOST_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_PORT_HEADER; +import static org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter.X_FORWARDED_PROTO_HEADER; + +/** + * @author Spencer Gibb + */ +public class XForwardedRequestHeadersFilterTests { + + @Test + public void remoteAddressIsNull() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), ".*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER) + .containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_HOST_HEADER)).isEqualTo("myhost"); + assertThat(headers.getFirst(X_FORWARDED_PORT_HEADER)).isEqualTo("80"); + assertThat(headers.getFirst(X_FORWARDED_PROTO_HEADER)).isEqualTo("http"); + } + + @Test + public void trustedProxiesConditionMatches() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, RestClientAutoConfiguration.class, + SslAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + GatewayServerMvcAutoConfiguration.class)) + .withPropertyValues(GatewayMvcProperties.PREFIX + ".trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedRequestHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, RestClientAutoConfiguration.class, + SslAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + GatewayServerMvcAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedRequestHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions + .assertThatThrownBy( + () -> new XForwardedRequestHeadersFilter(new XForwardedRequestHeadersFilterProperties(), "")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void xForwardedHeadersNotTrusted() throws Exception { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedXForwardedForNotAppended() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .remoteAddress("10.0.0.1:80") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .header(X_FORWARDED_FOR_HEADER, "10.0.0.10") + .buildRequest(null); + servletRequest.setRemoteHost("10.0.0.1"); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1") + .contains("10.0.0.1", "10.0.0.10"); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() { + MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .buildRequest(null); + servletRequest.setRemoteAddr(null); + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + + XForwardedRequestHeadersFilter filter = new XForwardedRequestHeadersFilter( + new XForwardedRequestHeadersFilterProperties(), "10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.apply(request.headers().asHttpHeaders(), request); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1"); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java index c0e6556c..d8bad981 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestUtils.java @@ -22,7 +22,11 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -public class TestUtils { +public final class TestUtils { + + private TestUtils() { + + } public static Map getMap(Map map, String mapKey) { assertThat(map).isNotEmpty().containsKey(mapKey); diff --git a/spring-cloud-gateway-server-mvc/src/test/resources/application.yml b/spring-cloud-gateway-server-mvc/src/test/resources/application.yml index 9bc1759e..94788e61 100644 --- a/spring-cloud-gateway-server-mvc/src/test/resources/application.yml +++ b/spring-cloud-gateway-server-mvc/src/test/resources/application.yml @@ -6,4 +6,8 @@ logging: org.springframework.retry: TRACE spring: mvc: - log-request-details: true \ No newline at end of file + log-request-details: true + cloud: + gateway: + mvc: + trusted-proxies: .* \ No newline at end of file diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java new file mode 100644 index 00000000..b8893728 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/DefaultNettyHttpForwardedHeaderHandler.java @@ -0,0 +1,137 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Copyright (c) 2020-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.cloud.gateway.config; + +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.netty.handler.codec.http.HttpRequest; +import reactor.netty.http.server.ConnectionInfo; +import reactor.netty.transport.AddressUtils; + +import static reactor.netty.http.server.ConnectionInfo.getDefaultHostPort; + +/** + * Default implementation for handling {@code X-Forwarded}/{@code Forwarded} headers. + * + * @author Andrey Shlykov + * @since 0.9.12 + */ +final class DefaultNettyHttpForwardedHeaderHandler implements BiFunction { + + static final DefaultNettyHttpForwardedHeaderHandler INSTANCE = new DefaultNettyHttpForwardedHeaderHandler(); + + static final String FORWARDED_HEADER = "Forwarded"; + static final String X_FORWARDED_IP_HEADER = "X-Forwarded-For"; + static final String X_FORWARDED_HOST_HEADER = "X-Forwarded-Host"; + static final String X_FORWARDED_PORT_HEADER = "X-Forwarded-Port"; + static final String X_FORWARDED_PROTO_HEADER = "X-Forwarded-Proto"; + + static final Pattern FORWARDED_HOST_PATTERN = Pattern.compile("host=\"?([^;,\"]+)\"?"); + static final Pattern FORWARDED_PROTO_PATTERN = Pattern.compile("proto=\"?([^;,\"]+)\"?"); + static final Pattern FORWARDED_FOR_PATTERN = Pattern.compile("for=\"?([^;,\"]+)\"?"); + + /** + * Specifies whether the Http Server applies a strict {@code Forwarded} header + * validation. By default, it is enabled and strict validation is used. + * @since 1.0.8 + * @deprecated The system property is used for backwards compatibility and will be + * removed in version 1.2.0. + */ + @Deprecated + static final String FORWARDED_HEADER_VALIDATION = "reactor.netty.http.server.forwarded.strictValidation"; + static final boolean DEFAULT_FORWARDED_HEADER_VALIDATION = Boolean + .parseBoolean(System.getProperty(FORWARDED_HEADER_VALIDATION, "true")); + + @Override + public ConnectionInfo apply(ConnectionInfo connectionInfo, HttpRequest request) { + String forwardedHeader = request.headers().get(FORWARDED_HEADER); + if (forwardedHeader != null) { + return parseForwardedInfo(connectionInfo, forwardedHeader); + } + return parseXForwardedInfo(connectionInfo, request); + } + + private ConnectionInfo parseForwardedInfo(ConnectionInfo connectionInfo, String forwardedHeader) { + String forwarded = forwardedHeader.split(",", 2)[0]; + Matcher protoMatcher = FORWARDED_PROTO_PATTERN.matcher(forwarded); + if (protoMatcher.find()) { + connectionInfo = connectionInfo.withScheme(protoMatcher.group(1).trim()); + } + Matcher hostMatcher = FORWARDED_HOST_PATTERN.matcher(forwarded); + if (hostMatcher.find()) { + connectionInfo = connectionInfo.withHostAddress(AddressUtils.parseAddress(hostMatcher.group(1), + getDefaultHostPort(connectionInfo.getScheme()), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + Matcher forMatcher = FORWARDED_FOR_PATTERN.matcher(forwarded); + if (forMatcher.find()) { + connectionInfo = connectionInfo.withRemoteAddress(AddressUtils.parseAddress(forMatcher.group(1).trim(), + connectionInfo.getRemoteAddress().getPort(), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + return connectionInfo; + } + + private ConnectionInfo parseXForwardedInfo(ConnectionInfo connectionInfo, HttpRequest request) { + String ipHeader = request.headers().get(X_FORWARDED_IP_HEADER); + if (ipHeader != null) { + connectionInfo = connectionInfo.withRemoteAddress( + AddressUtils.parseAddress(ipHeader.split(",", 2)[0], connectionInfo.getRemoteAddress().getPort())); + } + String protoHeader = request.headers().get(X_FORWARDED_PROTO_HEADER); + if (protoHeader != null) { + connectionInfo = connectionInfo.withScheme(protoHeader.split(",", 2)[0].trim()); + } + String hostHeader = request.headers().get(X_FORWARDED_HOST_HEADER); + if (hostHeader != null) { + connectionInfo = connectionInfo + .withHostAddress(AddressUtils.parseAddress(hostHeader.split(",", 2)[0].trim(), + getDefaultHostPort(connectionInfo.getScheme()), DEFAULT_FORWARDED_HEADER_VALIDATION)); + } + + String portHeader = request.headers().get(X_FORWARDED_PORT_HEADER); + if (portHeader != null && !portHeader.isEmpty()) { + String portStr = portHeader.split(",", 2)[0].trim(); + if (portStr.chars().allMatch(Character::isDigit)) { + int port = Integer.parseInt(portStr); + connectionInfo = connectionInfo.withHostAddress( + AddressUtils.createUnresolved(connectionInfo.getHostAddress().getHostString(), port), + connectionInfo.getHostName(), port); + } + else if (DEFAULT_FORWARDED_HEADER_VALIDATION) { + throw new IllegalArgumentException("Failed to parse a port from " + portHeader); + } + } + return connectionInfo; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index b5022c46..aa6afacc 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.config; import java.io.IOException; +import java.net.InetSocketAddress; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -62,6 +63,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.embedded.netty.NettyServerCustomizer; import org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint; import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint; import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; @@ -127,6 +129,7 @@ import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilte import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter; import org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter; import org.springframework.cloud.gateway.filter.headers.TransferEncodingNormalizationHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.TrustedProxies; import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; import org.springframework.cloud.gateway.filter.ratelimit.Bucket4jRateLimiter; import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; @@ -319,11 +322,11 @@ public class GatewayAutoConfiguration { } @Bean - @ConditionalOnProperty(name = "spring.cloud.gateway.server.webflux.forwarded.enabled", matchIfMissing = true) - public ForwardedHeadersFilter forwardedHeadersFilter(Environment env, ServerProperties serverProperties) { + @Conditional(TrustedProxies.ForwardedTrustedProxiesCondition.class) + public ForwardedHeadersFilter forwardedHeadersFilter(Environment env, ServerProperties serverProperties, GatewayProperties properties) { boolean forwardedByEnabled = env.getProperty("spring.cloud.gateway.server.webflux.forwarded.by.enabled", Boolean.class, false); - ForwardedHeadersFilter forwardedHeadersFilter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter forwardedHeadersFilter = new ForwardedHeadersFilter(properties.getTrustedProxies()); forwardedHeadersFilter.setForwardedByEnabled(forwardedByEnabled); forwardedHeadersFilter.setServerPort(serverProperties.getPort()); return forwardedHeadersFilter; @@ -337,9 +340,9 @@ public class GatewayAutoConfiguration { } @Bean - @ConditionalOnProperty(name = "spring.cloud.gateway.server.webflux.x-forwarded.enabled", matchIfMissing = true) - public XForwardedHeadersFilter xForwardedHeadersFilter() { - return new XForwardedHeadersFilter(); + @Conditional(TrustedProxies.XForwardedTrustedProxiesCondition.class) + public XForwardedHeadersFilter xForwardedHeadersFilter(GatewayProperties properties) { + return new XForwardedHeadersFilter(properties.getTrustedProxies()); } @Bean @@ -790,6 +793,21 @@ public class GatewayAutoConfiguration { }; } + @Bean + @TrustedProxies.ConditionalOnPropertyExists + public NettyServerCustomizer gatewayNettyServerCustomizer(GatewayProperties gatewayProperties) { + TrustedProxies trustedProxies = TrustedProxies.from(gatewayProperties.getTrustedProxies()); + + return httpServer -> httpServer.forwarded((connectionInfo, httpRequest) -> { + InetSocketAddress remoteAddress = connectionInfo.getRemoteAddress(); + if (remoteAddress != null && trustedProxies.isTrusted(remoteAddress.getHostString())) { + // update remote address + return DefaultNettyHttpForwardedHeaderHandler.INSTANCE.apply(connectionInfo, httpRequest); + } + return connectionInfo; + }); + } + @Bean public HttpClientSslConfigurer httpClientSslConfigurer(ServerProperties serverProperties, HttpClientProperties httpClientProperties, SslBundles bundles) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java index 67e85c6d..067498e1 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayProperties.java @@ -73,6 +73,12 @@ public class GatewayProperties { */ private boolean routeFilterCacheEnabled = false; + /** + * Regular expression defining proxies that are trusted when they appear in a + * Forwarded or X-Forwarded header. + */ + private String trustedProxies; + public boolean isRouteFilterCacheEnabled() { return routeFilterCacheEnabled; } @@ -116,6 +122,14 @@ public class GatewayProperties { this.failOnRouteDefinitionError = failOnRouteDefinitionError; } + public String getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(String trustedProxies) { + this.trustedProxies = trustedProxies; + } + @Override public String toString() { return new ToStringCreator(this).append("routes", routes) @@ -123,6 +137,7 @@ public class GatewayProperties { .append("streamingMediaTypes", streamingMediaTypes) .append("failOnRouteDefinitionError", failOnRouteDefinitionError) .append("routeFilterCacheEnabled", routeFilterCacheEnabled) + .append("trustedProxies", trustedProxies) .toString(); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java index e55871f3..78f6e7ff 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java @@ -29,7 +29,9 @@ import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.CollectionUtils; @@ -44,6 +46,8 @@ import org.springframework.web.server.ServerWebExchange; */ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(ForwardedHeadersFilter.class); + private Integer serverPort; private final Log logger = LogFactory.getLog(getClass()); @@ -55,6 +59,19 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { */ public static final String FORWARDED_HEADER = "Forwarded"; + private final TrustedProxies trustedProxies; + + @Deprecated + public ForwardedHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public ForwardedHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + /* for testing */ static List parse(List values) { ArrayList forwardeds = new ArrayList<>(); @@ -62,8 +79,11 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { return forwardeds; } for (String value : values) { - Forwarded forwarded = parse(value); - forwardeds.add(forwarded); + String[] forwardedValues = StringUtils.tokenizeToStringArray(value, ","); + for (String forwardedValue : forwardedValues) { + Forwarded forwarded = parse(forwardedValue); + forwardeds.add(forwarded); + } } return forwardeds; } @@ -114,6 +134,14 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { @Override public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) { ServerHttpRequest request = exchange.getRequest(); + + if (request.getRemoteAddress() != null + && !trustedProxies.isTrusted(request.getRemoteAddress().getHostString())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.getRemoteAddress())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -127,7 +155,10 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { List forwardeds = parse(original.get(FORWARDED_HEADER)); for (Forwarded f : forwardeds) { - updated.add(FORWARDED_HEADER, f.toHeaderValue()); + // only add if "for" value matches trustedProxies + if (trustedProxies.isTrusted(f.get("for"))) { + updated.add(FORWARDED_HEADER, f.toHeaderValue()); + } } // TODO: add new forwarded @@ -136,6 +167,7 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { Forwarded forwarded = new Forwarded().put("host", host).put("proto", uri.getScheme()); InetSocketAddress remoteAddress = request.getRemoteAddress(); + // TODO: only add if "remoteAddress" value matches trustedProxies if (remoteAddress != null) { // If remoteAddress is unresolved, calling getHostAddress() would cause a // NullPointerException. @@ -150,11 +182,14 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { forValue = "[" + forValue + "]"; } } - int port = remoteAddress.getPort(); - if (port >= 0) { - forValue = forValue + ":" + port; + if (trustedProxies.isTrusted(forValue)) { + // only add for value if trusted + int port = remoteAddress.getPort(); + if (port >= 0) { + forValue = forValue + ":" + port; + } + forwarded.put("for", forValue); } - forwarded.put("for", forValue); } if (forwardedByEnabled) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java new file mode 100644 index 00000000..732454e8 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/TrustedProxies.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.filter.headers; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.NoSuchElementException; +import java.util.regex.Pattern; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@FunctionalInterface +public interface TrustedProxies { + + /** + * Property name. + */ + String PROPERTY = GatewayProperties.PREFIX + ".trusted-proxies"; + + boolean isTrusted(String host); + + static TrustedProxies from(@NonNull String trustedProxies) { + Assert.hasText(trustedProxies, "trustedProxies must not be empty"); + Pattern pattern = Pattern.compile(trustedProxies); + return value -> pattern.matcher(value).matches(); + } + + class ForwardedTrustedProxiesCondition extends AllNestedConditions { + + public ForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".forwarded.enabled", matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists + static class OnTrustedProxiesNotEmpty { + + } + + } + + class XForwardedTrustedProxiesCondition extends AllNestedConditions { + + public XForwardedTrustedProxiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".x-forwarded.enabled", matchIfMissing = true) + static class OnPropertyEnabled { + + } + + @ConditionalOnPropertyExists + static class OnTrustedProxiesNotEmpty { + + } + + } + + class OnPropertyExistsCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + try { + String property = context.getEnvironment().getProperty(PROPERTY); + if (!StringUtils.hasText(property)) { + return ConditionOutcome.noMatch(PROPERTY + " property is not set or is empty."); + } + return ConditionOutcome.match(PROPERTY + " property is not empty."); + } + catch (NoSuchElementException e) { + return ConditionOutcome.noMatch("Missing required property 'value' of @ConditionalOnPropertyExists"); + } + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @Documented + @Conditional(OnPropertyExistsCondition.class) + @interface ConditionalOnPropertyExists { + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java index 2dea38fc..6dca67d5 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java @@ -20,9 +20,15 @@ import java.net.URI; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.core.Ordered; +import org.springframework.core.log.LogMessage; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.ObjectUtils; @@ -35,6 +41,8 @@ import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.G @ConfigurationProperties("spring.cloud.gateway.server.webflux.x-forwarded") public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { + private static final Log log = LogFactory.getLog(XForwardedHeadersFilter.class); + /** Default http port. */ public static final int HTTP_PORT = 80; @@ -98,6 +106,19 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { /** If appending X-Forwarded-Prefix as a list is enabled. */ private boolean prefixAppend = true; + private final TrustedProxies trustedProxies; + + @Deprecated + public XForwardedHeadersFilter() { + trustedProxies = s -> true; + log.warn(GatewayProperties.PREFIX + + ".trusted-proxies is not set. Using deprecated Constructor. Untrusted hosts might be added to Forwarded header."); + } + + public XForwardedHeadersFilter(String trustedProxiesRegex) { + trustedProxies = TrustedProxies.from(trustedProxiesRegex); + } + @Override public int getOrder() { return this.order; @@ -197,8 +218,15 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { @Override public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) { - ServerHttpRequest request = exchange.getRequest(); + + if (request.getRemoteAddress() != null + && !trustedProxies.isTrusted(request.getRemoteAddress().getHostString())) { + log.trace(LogMessage.format("Remote address not trusted. pattern %s remote address %s", trustedProxies, + request.getRemoteAddress())); + return input; + } + HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); @@ -206,9 +234,13 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { updated.addAll(entry.getKey(), entry.getValue()); } - if (isForEnabled() && request.getRemoteAddress() != null && request.getRemoteAddress().getAddress() != null) { - String remoteAddr = request.getRemoteAddress().getAddress().getHostAddress(); - write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend()); + if (isForEnabled()) { + String remoteAddr = null; + if (request.getRemoteAddress() != null && request.getRemoteAddress().getAddress() != null) { + remoteAddr = request.getRemoteAddress().getHostString(); + } + // match xforwarded for against trusted proxies + write(updated, X_FORWARDED_FOR_HEADER, remoteAddr, isForAppend(), trustedProxies::isTrusted); } String proto = request.getURI().getScheme(); @@ -284,17 +316,22 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { } private void write(HttpHeaders headers, String name, String value, boolean append) { - if (value == null) { - return; - } + write(headers, name, value, append, s -> true); + } + + private void write(HttpHeaders headers, String name, String value, boolean append, Predicate shouldWrite) { if (append) { - headers.add(name, value); + if (value != null) { + headers.add(name, value); + } // these headers should be treated as a single comma separated header - List values = headers.get(name); - String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); - headers.set(name, delimitedValue); + if (headers.containsKey(name)) { + List values = headers.get(name).stream().filter(shouldWrite).toList(); + String delimitedValue = StringUtils.collectionToCommaDelimitedString(values); + headers.set(name, delimitedValue); + } } - else { + else if (value != null && shouldWrite.test(value)) { headers.set(name, value); } } @@ -303,11 +340,6 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { return HTTPS_SCHEME.equals(scheme) ? HTTPS_PORT : HTTP_PORT; } - private boolean hasHeader(ServerHttpRequest request, String name) { - HttpHeaders headers = request.getHeaders(); - return headers.containsKey(name) && StringUtils.hasLength(headers.getFirst(name)); - } - private String toHostHeader(ServerHttpRequest request) { int port = request.getURI().getPort(); String host = request.getURI().getHost(); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java index 0e1445dd..ceaff580 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java @@ -58,8 +58,10 @@ import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint import org.springframework.cloud.gateway.config.GatewayAutoConfigurationTests.CustomHttpClientFactory.CustomSslConfigurer; import org.springframework.cloud.gateway.config.HttpClientProperties.Pool.LeasingStrategy; import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter; import org.springframework.cloud.gateway.filter.headers.GRPCRequestHeadersFilter; import org.springframework.cloud.gateway.filter.headers.GRPCResponseHeadersFilter; +import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; @@ -206,8 +208,8 @@ public class GatewayAutoConfigurationTests { "spring.security.oauth2.client.registration[test].redirect-uri=http://localhost/redirect", "spring.security.oauth2.client.registration[test].client-id=login-client") .run(context -> { - assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); - assertThat(context).hasSingleBean(TokenRelayGatewayFilterFactory.class); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class) + .hasSingleBean(TokenRelayGatewayFilterFactory.class); }); } @@ -225,8 +227,8 @@ public class GatewayAutoConfigurationTests { "spring.security.oauth2.client.registration[test].redirect-uri=http://localhost/redirect", "spring.security.oauth2.client.registration[test].client-id=login-client") .run(context -> { - assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class); - assertThat(context).hasBean("myReactiveOAuth2AuthorizedClientManager"); + assertThat(context).hasSingleBean(ReactiveOAuth2AuthorizedClientManager.class) + .hasBean("myReactiveOAuth2AuthorizedClientManager"); }); } @@ -290,8 +292,8 @@ public class GatewayAutoConfigurationTests { HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) .withPropertyValues("server.http2.enabled=true") .run(context -> { - assertThat(context).hasSingleBean(GRPCRequestHeadersFilter.class); - assertThat(context).hasSingleBean(GRPCResponseHeadersFilter.class); + assertThat(context).hasSingleBean(GRPCRequestHeadersFilter.class) + .hasSingleBean(GRPCResponseHeadersFilter.class); HttpClient httpClient = context.getBean(HttpClient.class); assertThat(httpClient.configuration().protocols()).contains(HttpProtocol.HTTP11, HttpProtocol.H2); }); @@ -305,8 +307,8 @@ public class GatewayAutoConfigurationTests { HttpClientCustomizedConfig.class, ServerPropertiesConfig.class)) .withPropertyValues("server.http2.enabled=false") .run(context -> { - assertThat(context).doesNotHaveBean(GRPCRequestHeadersFilter.class); - assertThat(context).doesNotHaveBean(GRPCResponseHeadersFilter.class); + assertThat(context).doesNotHaveBean(GRPCRequestHeadersFilter.class) + .doesNotHaveBean(GRPCResponseHeadersFilter.class); }); } @@ -337,6 +339,32 @@ public class GatewayAutoConfigurationTests { }); } + @Test + public void forwardedHeaderFiltersNotEnabledByDefault() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + ServerPropertiesConfig.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedHeadersFilter.class) + .doesNotHaveBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void forwardedHeaderFiltersEnabledWithProperties() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class, + ServerPropertiesConfig.class)) + .withPropertyValues("spring.cloud.gateway.forwarded.enabled=true", + "spring.cloud.gateway.x-forwarded.enabled=true", "spring.cloud.gateway.trusted-proxies=.*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedHeadersFilter.class) + .hasSingleBean(ForwardedHeadersFilter.class); + }); + } + @Configuration @EnableConfigurationProperties(ServerProperties.class) @AutoConfigureBefore(GatewayAutoConfiguration.class) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java index fdc20f33..a7445aeb 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java @@ -66,7 +66,8 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen @SpringBootTest(webEnvironment = RANDOM_PORT, properties = { "spring.cloud.gateway.server.webflux.httpclient.connect-timeout=500", "spring.cloud.gateway.server.webflux.httpclient.response-timeout=2s", - "logging.level.org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory=TRACE" }) + "logging.level.org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory=TRACE" + "spring.cloud.gateway.server.webflux.trusted-proxies=.*", "spring.cloud.gateway.server.webflux.x-forwarded.enabled=true" }) @DirtiesContext // default filter AddResponseHeader suppresses bug // https://github.com/spring-cloud/spring-cloud-gateway/issues/1315, diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java index 0bc91049..04283344 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilterTests.java @@ -24,10 +24,17 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.cloud.gateway.config.GatewayAutoConfiguration; import org.springframework.cloud.gateway.filter.headers.ForwardedHeadersFilter.Forwarded; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; @@ -60,7 +67,7 @@ public class ForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -80,26 +87,37 @@ public class ForwardedHeadersFilterTests { public void forwardedHeaderExists() throws UnknownHostException { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) - .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https; for=23.45.67.89") + .header(FORWARDED_HEADER, "for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); - assertThat(headers.get(FORWARDED_HEADER)).hasSize(2); + assertThat(headers.get(FORWARDED_HEADER)).hasSize(3); List forwardeds = ForwardedHeadersFilter.parse(headers.get(FORWARDED_HEADER)); - assertThat(forwardeds).hasSize(2); - Forwarded addedForwardedHeader = forwardeds.get(0); - Forwarded existingForwardedHeader = forwardeds.get(1); - - assertThat(existingForwardedHeader.getValues()).containsEntry("proto", "http") - .containsEntry("for", "\"10.0.0.1:80\""); - - assertThat(addedForwardedHeader.getValues()).containsEntry("proto", "https") - .containsEntry("for", "23.45.67.89"); + assertThat(forwardeds).hasSize(3); + Optional added = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").contains("10.0.0.1:80")) + .findFirst(); + assertThat(added).isPresent(); + added.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "http").containsEntry("for", "\"10.0.0.1:80\""); + }); + Optional existing = forwardeds.stream() + .filter(forwarded -> forwarded.get("for").equals("23.45.67.89")) + .findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("for", "23.45.67.89"); + }); + existing = forwardeds.stream().filter(forwarded -> forwarded.get("for").equals("12.34.56.78")).findFirst(); + assertThat(existing).isPresent(); + existing.ifPresent(forwarded -> { + assertThat(forwarded.getValues()).containsEntry("proto", "https").containsEntry("for", "12.34.56.78"); + }); } @Test @@ -108,7 +126,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -129,7 +147,7 @@ public class ForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -149,7 +167,7 @@ public class ForwardedHeadersFilterTests { .remoteAddress(InetSocketAddress.createUnresolved("unresolvable-hostname", 80)) .build(); - ForwardedHeadersFilter filter = new ForwardedHeadersFilter(); + ForwardedHeadersFilter filter = new ForwardedHeadersFilter(".*"); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -241,4 +259,83 @@ public class ForwardedHeadersFilterTests { Assertions.assertThat(forwarded.getValues()).containsEntry("by", "216.103.69.111"); } + @Test + public void trustedProxiesConditionMatches() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, SslAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .withPropertyValues("spring.cloud.gateway.trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, SslAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(ForwardedHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions.assertThatThrownBy(() -> new ForwardedHeadersFilter("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void forwardedHeadersNotTrusted() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).doesNotContainKeys(FORWARDED_HEADER); + } + + // verify that existing forwarded header is not forwarded if not trusted + @Test + public void untrustedForwardedForNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=\"127.0.0.1:80\",for=10.0.0.11") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + filtered = forwardedHeaders.stream().filter(value -> value.contains("10.0.0.11")).findFirst(); + assertThat(filtered).isNotEmpty(); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.HOST, "myhost") + .header(FORWARDED_HEADER, "proto=http;host=myhost;for=127.0.0.1") + .build(); + + ForwardedHeadersFilter filter = new ForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(FORWARDED_HEADER); + List forwardedHeaders = headers.get(FORWARDED_HEADER); + Optional filtered = forwardedHeaders.stream().filter(value -> value.contains("127.0.0.1")).findFirst(); + assertThat(filtered).isEmpty(); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java index 44698524..f6918803 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilterTests.java @@ -21,8 +21,16 @@ import java.net.InetSocketAddress; import java.net.URI; import java.util.LinkedHashSet; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.cloud.gateway.config.GatewayAutoConfiguration; +import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; @@ -43,17 +51,20 @@ import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.G */ public class XForwardedHeadersFilterTests { + public static final String ALLOW_ALL_REGEX = ".*"; + @Test public void remoteAddressIsNull() { MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); - assertThat(headers).containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER) + .containsKeys(X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, X_FORWARDED_PROTO_HEADER); assertThat(headers.getFirst(X_FORWARDED_HOST_HEADER)).isEqualTo("localhost:8080"); assertThat(headers.getFirst(X_FORWARDED_PORT_HEADER)).isEqualTo("8080"); @@ -67,7 +78,7 @@ public class XForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -87,7 +98,7 @@ public class XForwardedHeadersFilterTests { .header(HttpHeaders.HOST, "myhost") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -110,7 +121,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_PROTO_HEADER, "https") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -134,7 +145,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_PREFIX_HEADER, "/prefix") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setForAppend(false); filter.setHostAppend(false); filter.setPortAppend(false); @@ -159,7 +170,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -184,7 +195,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -210,7 +221,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); @@ -232,7 +243,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); filter.setForEnabled(false); @@ -258,7 +269,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setPrefixAppend(true); filter.setPrefixEnabled(true); filter.setForEnabled(false); @@ -284,7 +295,7 @@ public class XForwardedHeadersFilterTests { .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); filter.setForEnabled(false); filter.setHostEnabled(false); filter.setPortEnabled(false); @@ -303,7 +314,7 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_FOR_HEADER, "10.0.0.1") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); @@ -319,11 +330,92 @@ public class XForwardedHeadersFilterTests { .header(X_FORWARDED_FOR_HEADER, "10.0.0.1") .build(); - XForwardedHeadersFilter filter = new XForwardedHeadersFilter(); + XForwardedHeadersFilter filter = new XForwardedHeadersFilter(ALLOW_ALL_REGEX); HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); assertThat(headers).doesNotContainKeys(X_FORWARDED_PROTO_HEADER, X_FORWARDED_HOST_HEADER); } + @Test + public void trustedProxiesConditionMatches() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, SslAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .withPropertyValues(GatewayProperties.PREFIX + ".trusted-proxies=11\\.0\\.0\\..*") + .run(context -> { + assertThat(context).hasSingleBean(XForwardedHeadersFilter.class); + }); + } + + @Test + public void trustedProxiesConditionDoesNotMatch() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, SslAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, GatewayAutoConfiguration.class)) + .run(context -> { + assertThat(context).doesNotHaveBean(XForwardedHeadersFilter.class); + }); + } + + @Test + public void emptyTrustedProxiesFails() { + Assertions.assertThatThrownBy(() -> new XForwardedHeadersFilter("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void xForwardedHeadersNotTrusted() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("11\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).doesNotContainKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + } + + // : verify that existing x-forwarded-* headers are not forwarded + // if x-forwarded-for is not trusted + @Test + public void untrustedXForwardedForNotAppended() throws Exception { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .remoteAddress(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), 80)) + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .header(X_FORWARDED_FOR_HEADER, "10.0.0.10") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1") + .contains("10.0.0.1", "10.0.0.10"); + } + + @Test + public void remoteAdddressIsNullUnTrustedProxyNotAppended() { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost:8080/get") + .header(HttpHeaders.HOST, "myhost") + .header(X_FORWARDED_FOR_HEADER, "127.0.0.1") + .build(); + + XForwardedHeadersFilter filter = new XForwardedHeadersFilter("10\\.0\\.0\\..*"); + + HttpHeaders headers = filter.filter(request.getHeaders(), MockServerWebExchange.from(request)); + + assertThat(headers).containsKeys(X_FORWARDED_FOR_HEADER, X_FORWARDED_HOST_HEADER, X_FORWARDED_PORT_HEADER, + X_FORWARDED_PROTO_HEADER); + + assertThat(headers.getFirst(X_FORWARDED_FOR_HEADER)).doesNotContain("127.0.0.1"); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java index 8af9e456..2ac25e73 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java @@ -63,7 +63,9 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen import static org.springframework.cloud.gateway.test.TestUtils.getMap; @SpringBootTest(webEnvironment = RANDOM_PORT, - properties = "spring.cloud.gateway.server.webflux.forwarded.by.enabled=true") + properties = { "spring.cloud.gateway.server.webflux.forwarded.by.enabled=true", "spring.cloud.gateway.server.webflux.forwarded.enabled=true", "spring.cloud.gateway.server.webflux.x-forwarded.enabled=true", + "spring.cloud.gateway.server.webflux.trusted-proxies=.*", + "logging.level.org.springframework.cloud.gateway.filter.headers=TRACE" }) @DirtiesContext @SuppressWarnings("unchecked") @ExtendWith(OutputCaptureExtension.class) diff --git a/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml b/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml index dbb64c4d..8dfd1d08 100644 --- a/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml +++ b/spring-cloud-gateway-server/src/test/resources/application-remote-address.yml @@ -22,3 +22,4 @@ spring: filters: - SetPath=/httpbin/ - SetStatus=200 + trusted-proxies: .*