mirror of
				https://github.com/spring-projects/spring-security.git
				synced 2025-10-31 06:38:42 +00:00 
			
		
		
		
	Merge branch '6.2.x' into 6.3.x
Add Firewall for WebFlux Closes gh-15967
This commit is contained in:
		
						commit
						1528c421bd
					
				| @ -155,6 +155,7 @@ | ||||
| *** xref:reactive/exploits/csrf.adoc[CSRF] | ||||
| *** xref:reactive/exploits/headers.adoc[Headers] | ||||
| *** xref:reactive/exploits/http.adoc[HTTP Requests] | ||||
| *** xref:reactive/exploits/firewall.adoc[] | ||||
| ** Integrations | ||||
| *** xref:reactive/integrations/cors.adoc[CORS] | ||||
| *** xref:reactive/integrations/rsocket.adoc[RSocket] | ||||
|  | ||||
							
								
								
									
										202
									
								
								docs/modules/ROOT/pages/reactive/exploits/firewall.adoc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								docs/modules/ROOT/pages/reactive/exploits/firewall.adoc
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,202 @@ | ||||
| [[webflux-serverwebexchangefirewall]] | ||||
| = ServerWebExchangeFirewall | ||||
| 
 | ||||
| There are various ways a request can be created by malicious users that can exploit applications. | ||||
| Spring Security provides the `ServerWebExchangeFirewall` to allow rejecting requests that look malicious. | ||||
| The default implementation is `StrictServerWebExchangeFirewall` which rejects malicious requests. | ||||
| 
 | ||||
| For example a request could contain path-traversal sequences (such as `/../`) or multiple forward slashes (`//`) that could also cause pattern-matches to fail. | ||||
| Some containers normalize these out before performing the servlet mapping, but others do not. | ||||
| To protect against issues like these, `WebFilterChainProxy` uses a `ServerWebExchangeFirewall` strategy to check and wrap the request. | ||||
| By default, un-normalized requests are automatically rejected, and path parameters are removed for matching purposes. | ||||
| (So, for example, an original request path of `/secure;hack=1/somefile.html;hack=2` is returned as `/secure/somefile.html`.) | ||||
| It is, therefore, essential that a `WebFilterChainProxy` is used. | ||||
| 
 | ||||
| In practice, we recommend that you use method security at your service layer, to control access to your application, rather than rely entirely on the use of security constraints defined at the web-application level. | ||||
| URLs change, and it is difficult to take into account all the possible URLs that an application might support and how requests might be manipulated. | ||||
| You should restrict yourself to using a few simple patterns that are simple to understand. | ||||
| Always try to use a "`deny-by-default`" approach, where you have a catch-all wildcard (`/**` or `**`) defined last to deny access. | ||||
| 
 | ||||
| Security defined at the service layer is much more robust and harder to bypass, so you should always take advantage of Spring Security's method security options. | ||||
| 
 | ||||
| You can customize the `ServerWebExchangeFirewall` by exposing it as a Bean. | ||||
| 
 | ||||
| .Allow Matrix Variables | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary"] | ||||
| ---- | ||||
| @Bean | ||||
| public StrictServerWebExchangeFirewall httpFirewall() { | ||||
|     StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
|     firewall.setAllowSemicolon(true); | ||||
|     return firewall; | ||||
| } | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary"] | ||||
| ---- | ||||
| @Bean | ||||
| fun httpFirewall(): StrictServerWebExchangeFirewall { | ||||
|     val firewall = StrictServerWebExchangeFirewall() | ||||
|     firewall.setAllowSemicolon(true) | ||||
|     return firewall | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| To protect against https://www.owasp.org/index.php/Cross_Site_Tracing[Cross Site Tracing (XST)] and https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)[HTTP Verb Tampering], the `StrictServerWebExchangeFirewall` provides an allowed list of valid HTTP methods that are allowed. | ||||
| The default valid methods are `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, and `PUT`. | ||||
| If your application needs to modify the valid methods, you can configure a custom `StrictServerWebExchangeFirewall` bean. | ||||
| The following example allows only HTTP `GET` and `POST` methods: | ||||
| 
 | ||||
| 
 | ||||
| .Allow Only GET & POST | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary"] | ||||
| ---- | ||||
| @Bean | ||||
| public StrictServerWebExchangeFirewall httpFirewall() { | ||||
|     StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
|     firewall.setAllowedHttpMethods(Arrays.asList("GET", "POST")); | ||||
|     return firewall; | ||||
| } | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary"] | ||||
| ---- | ||||
| @Bean | ||||
| fun httpFirewall(): StrictServerWebExchangeFirewall { | ||||
|     val firewall = StrictServerWebExchangeFirewall() | ||||
|     firewall.setAllowedHttpMethods(listOf("GET", "POST")) | ||||
|     return firewall | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| If you must allow any HTTP method (not recommended), you can use `StrictServerWebExchangeFirewall.setUnsafeAllowAnyHttpMethod(true)`. | ||||
| Doing so entirely disables validation of the HTTP method. | ||||
| 
 | ||||
| 
 | ||||
| [[webflux-serverwebexchangefirewall-headers-parameters]] | ||||
| `StrictServerWebExchangeFirewall` also checks header names and values and parameter names. | ||||
| It requires that each character have a defined code point and not be a control character. | ||||
| 
 | ||||
| This requirement can be relaxed or adjusted as necessary by using the following methods: | ||||
| 
 | ||||
| * `StrictServerWebExchangeFirewall#setAllowedHeaderNames(Predicate)` | ||||
| * `StrictServerWebExchangeFirewall#setAllowedHeaderValues(Predicate)` | ||||
| * `StrictServerWebExchangeFirewall#setAllowedParameterNames(Predicate)` | ||||
| 
 | ||||
| [NOTE] | ||||
| ==== | ||||
| Parameter values can be also controlled with `setAllowedParameterValues(Predicate)`. | ||||
| ==== | ||||
| 
 | ||||
| For example, to switch off this check, you can wire your `StrictServerWebExchangeFirewall` with `Predicate` instances that always return `true`: | ||||
| 
 | ||||
| .Allow Any Header Name, Header Value, and Parameter Name | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary"] | ||||
| ---- | ||||
| @Bean | ||||
| public StrictServerWebExchangeFirewall httpFirewall() { | ||||
|     StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
|     firewall.setAllowedHeaderNames((header) -> true); | ||||
|     firewall.setAllowedHeaderValues((header) -> true); | ||||
|     firewall.setAllowedParameterNames((parameter) -> true); | ||||
|     return firewall; | ||||
| } | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary"] | ||||
| ---- | ||||
| @Bean | ||||
| fun httpFirewall(): StrictServerWebExchangeFirewall { | ||||
|     val firewall = StrictServerWebExchangeFirewall() | ||||
|     firewall.setAllowedHeaderNames { true } | ||||
|     firewall.setAllowedHeaderValues { true } | ||||
|     firewall.setAllowedParameterNames { true } | ||||
|     return firewall | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| Alternatively, there might be a specific value that you need to allow. | ||||
| 
 | ||||
| For example, iPhone Xʀ uses a `User-Agent` that includes a character that is not in the ISO-8859-1 charset. | ||||
| Due to this fact, some application servers parse this value into two separate characters, the latter being an undefined character. | ||||
| 
 | ||||
| You can address this with the `setAllowedHeaderValues` method: | ||||
| 
 | ||||
| .Allow Certain User Agents | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary"] | ||||
| ---- | ||||
| @Bean | ||||
| public StrictServerWebExchangeFirewall httpFirewall() { | ||||
|     StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
|     Pattern allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*"); | ||||
|     Pattern userAgent = ...; | ||||
|     firewall.setAllowedHeaderValues((header) -> allowed.matcher(header).matches() || userAgent.matcher(header).matches()); | ||||
|     return firewall; | ||||
| } | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary"] | ||||
| ---- | ||||
| @Bean | ||||
| fun httpFirewall(): StrictServerWebExchangeFirewall { | ||||
|     val firewall = StrictServerWebExchangeFirewall() | ||||
|     val allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*") | ||||
|     val userAgent = Pattern.compile(...) | ||||
|     firewall.setAllowedHeaderValues { allowed.matcher(it).matches() || userAgent.matcher(it).matches() } | ||||
|     return firewall | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| 
 | ||||
| In the case of header values, you may instead consider parsing them as UTF-8 at verification time: | ||||
| 
 | ||||
| .Parse Headers As UTF-8 | ||||
| [tabs] | ||||
| ====== | ||||
| Java:: | ||||
| + | ||||
| [source,java,role="primary"] | ||||
| ---- | ||||
| firewall.setAllowedHeaderValues((header) -> { | ||||
|     String parsed = new String(header.getBytes(ISO_8859_1), UTF_8); | ||||
|     return allowed.matcher(parsed).matches(); | ||||
| }); | ||||
| ---- | ||||
| 
 | ||||
| Kotlin:: | ||||
| + | ||||
| [source,kotlin,role="secondary"] | ||||
| ---- | ||||
| firewall.setAllowedHeaderValues { | ||||
|     val parsed = String(header.getBytes(ISO_8859_1), UTF_8) | ||||
|     return allowed.matcher(parsed).matches() | ||||
| } | ||||
| ---- | ||||
| ====== | ||||
| @ -1,5 +1,5 @@ | ||||
| /* | ||||
|  * Copyright 2002-2022 the original author or authors. | ||||
|  * Copyright 2002-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. | ||||
| @ -20,11 +20,15 @@ import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import jakarta.servlet.FilterChain; | ||||
| import reactor.core.publisher.Flux; | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.security.web.SecurityFilterChain; | ||||
| import org.springframework.security.web.server.firewall.HttpStatusExchangeRejectedHandler; | ||||
| import org.springframework.security.web.server.firewall.ServerExchangeRejectedException; | ||||
| import org.springframework.security.web.server.firewall.ServerExchangeRejectedHandler; | ||||
| import org.springframework.security.web.server.firewall.ServerWebExchangeFirewall; | ||||
| import org.springframework.security.web.server.firewall.StrictServerWebExchangeFirewall; | ||||
| import org.springframework.util.Assert; | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| import org.springframework.web.server.WebFilter; | ||||
| @ -43,6 +47,10 @@ public class WebFilterChainProxy implements WebFilter { | ||||
| 
 | ||||
| 	private WebFilterChainDecorator filterChainDecorator = new DefaultWebFilterChainDecorator(); | ||||
| 
 | ||||
| 	private ServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
| 
 | ||||
| 	private ServerExchangeRejectedHandler exchangeRejectedHandler = new HttpStatusExchangeRejectedHandler(); | ||||
| 
 | ||||
| 	public WebFilterChainProxy(List<SecurityWebFilterChain> filters) { | ||||
| 		this.filters = filters; | ||||
| 	} | ||||
| @ -53,14 +61,41 @@ public class WebFilterChainProxy implements WebFilter { | ||||
| 
 | ||||
| 	@Override | ||||
| 	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { | ||||
| 		return this.firewall.getFirewalledExchange(exchange) | ||||
| 			.flatMap((firewalledExchange) -> filterFirewalledExchange(firewalledExchange, chain)) | ||||
| 			.onErrorResume(ServerExchangeRejectedException.class, | ||||
| 					(rejected) -> this.exchangeRejectedHandler.handle(exchange, rejected).then(Mono.empty())); | ||||
| 	} | ||||
| 
 | ||||
| 	private Mono<Void> filterFirewalledExchange(ServerWebExchange firewalledExchange, WebFilterChain chain) { | ||||
| 		return Flux.fromIterable(this.filters) | ||||
| 			.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(exchange)) | ||||
| 			.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(firewalledExchange)) | ||||
| 			.next() | ||||
| 			.switchIfEmpty( | ||||
| 					Mono.defer(() -> this.filterChainDecorator.decorate(chain).filter(exchange).then(Mono.empty()))) | ||||
| 			.switchIfEmpty(Mono | ||||
| 				.defer(() -> this.filterChainDecorator.decorate(chain).filter(firewalledExchange).then(Mono.empty()))) | ||||
| 			.flatMap((securityWebFilterChain) -> securityWebFilterChain.getWebFilters().collectList()) | ||||
| 			.map((filters) -> this.filterChainDecorator.decorate(chain, filters)) | ||||
| 			.flatMap((securedChain) -> securedChain.filter(exchange)); | ||||
| 			.flatMap((securedChain) -> securedChain.filter(firewalledExchange)); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Protects the application using the provided | ||||
| 	 * {@link StrictServerWebExchangeFirewall}. | ||||
| 	 * @param firewall the {@link StrictServerWebExchangeFirewall} to use. Cannot be null. | ||||
| 	 * @since 6.4 | ||||
| 	 */ | ||||
| 	public void setFirewall(ServerWebExchangeFirewall firewall) { | ||||
| 		Assert.notNull(firewall, "firewall cannot be null"); | ||||
| 		this.firewall = firewall; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Handles {@link ServerExchangeRejectedException} when the | ||||
| 	 * {@link ServerWebExchangeFirewall} rejects the provided {@link ServerWebExchange}. | ||||
| 	 * @param exchangeRejectedHandler the {@link ServerExchangeRejectedHandler} to use. | ||||
| 	 */ | ||||
| 	public void setExchangeRejectedHandler(ServerExchangeRejectedHandler exchangeRejectedHandler) { | ||||
| 		this.exchangeRejectedHandler = exchangeRejectedHandler; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
|  | ||||
| @ -0,0 +1,66 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| import org.apache.commons.logging.Log; | ||||
| import org.apache.commons.logging.LogFactory; | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.core.log.LogMessage; | ||||
| import org.springframework.http.HttpStatus; | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| 
 | ||||
| /** | ||||
|  * A simple implementation of {@link ServerExchangeRejectedHandler} that sends an error | ||||
|  * with configurable status code. | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| public class HttpStatusExchangeRejectedHandler implements ServerExchangeRejectedHandler { | ||||
| 
 | ||||
| 	private static final Log logger = LogFactory.getLog(HttpStatusExchangeRejectedHandler.class); | ||||
| 
 | ||||
| 	private final HttpStatus status; | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Constructs an instance which uses {@code 400} as response code. | ||||
| 	 */ | ||||
| 	public HttpStatusExchangeRejectedHandler() { | ||||
| 		this(HttpStatus.BAD_REQUEST); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Constructs an instance which uses a configurable http code as response. | ||||
| 	 * @param status http status code to use | ||||
| 	 */ | ||||
| 	public HttpStatusExchangeRejectedHandler(HttpStatus status) { | ||||
| 		this.status = status; | ||||
| 	} | ||||
| 
 | ||||
| 	@Override | ||||
| 	public Mono<Void> handle(ServerWebExchange exchange, | ||||
| 			ServerExchangeRejectedException serverExchangeRejectedException) { | ||||
| 		return Mono.fromRunnable(() -> { | ||||
| 			logger.debug( | ||||
| 					LogMessage.format("Rejecting request due to: %s", serverExchangeRejectedException.getMessage()), | ||||
| 					serverExchangeRejectedException); | ||||
| 			exchange.getResponse().setStatusCode(this.status); | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,31 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| /** | ||||
|  * Thrown when a {@link org.springframework.web.server.ServerWebExchange} is rejected. | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| public class ServerExchangeRejectedException extends RuntimeException { | ||||
| 
 | ||||
| 	public ServerExchangeRejectedException(String message) { | ||||
| 		super(message); | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,39 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| 
 | ||||
| /** | ||||
|  * Handles {@link ServerExchangeRejectedException} thrown by | ||||
|  * {@link ServerWebExchangeFirewall}. | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| public interface ServerExchangeRejectedHandler { | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Handles an request rejected failure. | ||||
| 	 * @param exchange the {@link ServerWebExchange} that was rejected | ||||
| 	 * @param serverExchangeRejectedException that caused the invocation | ||||
| 	 */ | ||||
| 	Mono<Void> handle(ServerWebExchange exchange, ServerExchangeRejectedException serverExchangeRejectedException); | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,46 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| 
 | ||||
| /** | ||||
|  * Interface which can be used to reject potentially dangerous requests and/or wrap them | ||||
|  * to control their behaviour. | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| public interface ServerWebExchangeFirewall { | ||||
| 
 | ||||
| 	/** | ||||
| 	 * An implementation of {@link StrictServerWebExchangeFirewall} that does nothing. | ||||
| 	 * This is considered insecure and not recommended. | ||||
| 	 */ | ||||
| 	ServerWebExchangeFirewall INSECURE_NOOP = (exchange) -> Mono.just(exchange); | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Get a {@link ServerWebExchange} that has firewall rules applied to it. | ||||
| 	 * @param exchange the {@link ServerWebExchange} to apply firewall rules to. | ||||
| 	 * @return the {@link ServerWebExchange} that has firewall rules applied to it. | ||||
| 	 * @throws ServerExchangeRejectedException when a rule is broken. | ||||
| 	 */ | ||||
| 	Mono<ServerWebExchange> getFirewalledExchange(ServerWebExchange exchange); | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,790 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| import java.util.Arrays; | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.HashSet; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.Set; | ||||
| import java.util.function.Predicate; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.http.HttpHeaders; | ||||
| import org.springframework.http.HttpMethod; | ||||
| import org.springframework.http.server.reactive.ServerHttpRequest; | ||||
| import org.springframework.http.server.reactive.ServerHttpRequestDecorator; | ||||
| import org.springframework.http.server.reactive.ServerHttpResponse; | ||||
| import org.springframework.util.Assert; | ||||
| import org.springframework.util.MultiValueMap; | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| import org.springframework.web.server.ServerWebExchangeDecorator; | ||||
| 
 | ||||
| /** | ||||
|  * <p> | ||||
|  * A strict implementation of {@link ServerWebExchangeFirewall} that rejects any | ||||
|  * suspicious requests with a {@link ServerExchangeRejectedException}. | ||||
|  * </p> | ||||
|  * <p> | ||||
|  * The following rules are applied to the firewall: | ||||
|  * </p> | ||||
|  * <ul> | ||||
|  * <li>Rejects HTTP methods that are not allowed. This specified to block | ||||
|  * <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)">HTTP Verb | ||||
|  * tampering and XST attacks</a>. See {@link #setAllowedHttpMethods(Collection)}</li> | ||||
|  * <li>Rejects URLs that are not normalized to avoid bypassing security constraints. There | ||||
|  * is no way to disable this as it is considered extremely risky to disable this | ||||
|  * constraint. A few options to allow this behavior is to normalize the request prior to | ||||
|  * the firewall or using | ||||
|  * {@link org.springframework.security.web.firewall.DefaultHttpFirewall} instead. Please | ||||
|  * keep in mind that normalizing the request is fragile and why requests are rejected | ||||
|  * rather than normalized.</li> | ||||
|  * <li>Rejects URLs that contain characters that are not printable ASCII characters. There | ||||
|  * is no way to disable this as it is considered extremely risky to disable this | ||||
|  * constraint.</li> | ||||
|  * <li>Rejects URLs that contain semicolons. See {@link #setAllowSemicolon(boolean)}</li> | ||||
|  * <li>Rejects URLs that contain a URL encoded slash. See | ||||
|  * {@link #setAllowUrlEncodedSlash(boolean)}</li> | ||||
|  * <li>Rejects URLs that contain a backslash. See {@link #setAllowBackSlash(boolean)}</li> | ||||
|  * <li>Rejects URLs that contain a null character. See {@link #setAllowNull(boolean)}</li> | ||||
|  * <li>Rejects URLs that contain a URL encoded percent. See | ||||
|  * {@link #setAllowUrlEncodedPercent(boolean)}</li> | ||||
|  * <li>Rejects hosts that are not allowed. See {@link #setAllowedHostnames(Predicate)} | ||||
|  * </li> | ||||
|  * <li>Reject headers names that are not allowed. See | ||||
|  * {@link #setAllowedHeaderNames(Predicate)}</li> | ||||
|  * <li>Reject headers values that are not allowed. See | ||||
|  * {@link #setAllowedHeaderValues(Predicate)}</li> | ||||
|  * <li>Reject parameter names that are not allowed. See | ||||
|  * {@link #setAllowedParameterNames(Predicate)}</li> | ||||
|  * <li>Reject parameter values that are not allowed. See | ||||
|  * {@link #setAllowedParameterValues(Predicate)}</li> | ||||
|  * </ul> | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| public class StrictServerWebExchangeFirewall implements ServerWebExchangeFirewall { | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Used to specify to {@link #setAllowedHttpMethods(Collection)} that any HTTP method | ||||
| 	 * should be allowed. | ||||
| 	 */ | ||||
| 	private static final Set<HttpMethod> ALLOW_ANY_HTTP_METHOD = Collections.emptySet(); | ||||
| 
 | ||||
| 	private static final String ENCODED_PERCENT = "%25"; | ||||
| 
 | ||||
| 	private static final String PERCENT = "%"; | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_ENCODED_PERIOD = Collections | ||||
| 		.unmodifiableList(Arrays.asList("%2e", "%2E")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_SEMICOLON = Collections | ||||
| 		.unmodifiableList(Arrays.asList(";", "%3b", "%3B")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_FORWARDSLASH = Collections | ||||
| 		.unmodifiableList(Arrays.asList("%2f", "%2F")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH = Collections | ||||
| 		.unmodifiableList(Arrays.asList("//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_BACKSLASH = Collections | ||||
| 		.unmodifiableList(Arrays.asList("\\", "%5c", "%5C")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_NULL = Collections.unmodifiableList(Arrays.asList("\0", "%00")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_LF = Collections.unmodifiableList(Arrays.asList("\n", "%0a", "%0A")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_CR = Collections.unmodifiableList(Arrays.asList("\r", "%0d", "%0D")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_LINE_SEPARATOR = Collections.unmodifiableList(Arrays.asList("\u2028")); | ||||
| 
 | ||||
| 	private static final List<String> FORBIDDEN_PARAGRAPH_SEPARATOR = Collections | ||||
| 		.unmodifiableList(Arrays.asList("\u2029")); | ||||
| 
 | ||||
| 	private Set<String> encodedUrlBlocklist = new HashSet<>(); | ||||
| 
 | ||||
| 	private Set<String> decodedUrlBlocklist = new HashSet<>(); | ||||
| 
 | ||||
| 	private Set<HttpMethod> allowedHttpMethods = createDefaultAllowedHttpMethods(); | ||||
| 
 | ||||
| 	private Predicate<String> allowedHostnames = (hostname) -> true; | ||||
| 
 | ||||
| 	private static final Pattern ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN = Pattern | ||||
| 		.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*"); | ||||
| 
 | ||||
| 	private static final Predicate<String> ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE = ( | ||||
| 			s) -> ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN.matcher(s).matches(); | ||||
| 
 | ||||
| 	private static final Pattern HEADER_VALUE_PATTERN = Pattern.compile("[\\p{IsAssigned}&&[[^\\p{IsControl}]||\\t]]*"); | ||||
| 
 | ||||
| 	private static final Predicate<String> HEADER_VALUE_PREDICATE = (s) -> s == null | ||||
| 			|| HEADER_VALUE_PATTERN.matcher(s).matches(); | ||||
| 
 | ||||
| 	private Predicate<String> allowedHeaderNames = ALLOWED_HEADER_NAMES; | ||||
| 
 | ||||
| 	public static final Predicate<String> ALLOWED_HEADER_NAMES = ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE; | ||||
| 
 | ||||
| 	private Predicate<String> allowedHeaderValues = ALLOWED_HEADER_VALUES; | ||||
| 
 | ||||
| 	public static final Predicate<String> ALLOWED_HEADER_VALUES = HEADER_VALUE_PREDICATE; | ||||
| 
 | ||||
| 	private Predicate<String> allowedParameterNames = ALLOWED_PARAMETER_NAMES; | ||||
| 
 | ||||
| 	public static final Predicate<String> ALLOWED_PARAMETER_NAMES = ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE; | ||||
| 
 | ||||
| 	private Predicate<String> allowedParameterValues = ALLOWED_PARAMETER_VALUES; | ||||
| 
 | ||||
| 	public static final Predicate<String> ALLOWED_PARAMETER_VALUES = (value) -> true; | ||||
| 
 | ||||
| 	public StrictServerWebExchangeFirewall() { | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_SEMICOLON); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_FORWARDSLASH); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_BACKSLASH); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_NULL); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_LF); | ||||
| 		urlBlocklistsAddAll(FORBIDDEN_CR); | ||||
| 
 | ||||
| 		this.encodedUrlBlocklist.add(ENCODED_PERCENT); | ||||
| 		this.encodedUrlBlocklist.addAll(FORBIDDEN_ENCODED_PERIOD); | ||||
| 		this.decodedUrlBlocklist.add(PERCENT); | ||||
| 		this.decodedUrlBlocklist.addAll(FORBIDDEN_LINE_SEPARATOR); | ||||
| 		this.decodedUrlBlocklist.addAll(FORBIDDEN_PARAGRAPH_SEPARATOR); | ||||
| 	} | ||||
| 
 | ||||
| 	public Set<String> getEncodedUrlBlocklist() { | ||||
| 		return this.encodedUrlBlocklist; | ||||
| 	} | ||||
| 
 | ||||
| 	public Set<String> getDecodedUrlBlocklist() { | ||||
| 		return this.decodedUrlBlocklist; | ||||
| 	} | ||||
| 
 | ||||
| 	@Override | ||||
| 	public Mono<ServerWebExchange> getFirewalledExchange(ServerWebExchange exchange) { | ||||
| 		return Mono.fromCallable(() -> { | ||||
| 			ServerHttpRequest request = exchange.getRequest(); | ||||
| 			rejectForbiddenHttpMethod(request); | ||||
| 			rejectedBlocklistedUrls(request); | ||||
| 			rejectedUntrustedHosts(request); | ||||
| 			if (!isNormalized(request)) { | ||||
| 				throw new ServerExchangeRejectedException( | ||||
| 						"The request was rejected because the URL was not normalized"); | ||||
| 			} | ||||
| 
 | ||||
| 			exchange.getResponse().beforeCommit(() -> Mono.fromRunnable(() -> { | ||||
| 				ServerHttpResponse response = exchange.getResponse(); | ||||
| 				HttpHeaders headers = response.getHeaders(); | ||||
| 				for (Map.Entry<String, List<String>> header : headers.entrySet()) { | ||||
| 					String headerName = header.getKey(); | ||||
| 					List<String> headerValues = header.getValue(); | ||||
| 					for (String headerValue : headerValues) { | ||||
| 						validateCrlf(headerName, headerValue); | ||||
| 					} | ||||
| 				} | ||||
| 			})); | ||||
| 			return new StrictFirewallServerWebExchange(exchange); | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private static void validateCrlf(String name, String value) { | ||||
| 		Assert.isTrue(!hasCrlf(name) && !hasCrlf(value), () -> "Invalid characters (CR/LF) in header " + name); | ||||
| 	} | ||||
| 
 | ||||
| 	private static boolean hasCrlf(String value) { | ||||
| 		return value != null && (value.indexOf('\n') != -1 || value.indexOf('\r') != -1); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Sets if any HTTP method is allowed. If this set to true, then no validation on the | ||||
| 	 * HTTP method will be performed. This can open the application up to | ||||
| 	 * <a href="https://www.owasp.org/index.php/Test_HTTP_Methods_(OTG-CONFIG-006)"> HTTP | ||||
| 	 * Verb tampering and XST attacks</a> | ||||
| 	 * @param unsafeAllowAnyHttpMethod if true, disables HTTP method validation, else | ||||
| 	 * resets back to the defaults. Default is false. | ||||
| 	 * @since 5.1 | ||||
| 	 * @see #setAllowedHttpMethods(Collection) | ||||
| 	 */ | ||||
| 	public void setUnsafeAllowAnyHttpMethod(boolean unsafeAllowAnyHttpMethod) { | ||||
| 		this.allowedHttpMethods = unsafeAllowAnyHttpMethod ? ALLOW_ANY_HTTP_METHOD : createDefaultAllowedHttpMethods(); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines which HTTP methods should be allowed. The default is to allow "DELETE", | ||||
| 	 * "GET", "HEAD", "OPTIONS", "PATCH", "POST", and "PUT". | ||||
| 	 * </p> | ||||
| 	 * @param allowedHttpMethods the case-sensitive collection of HTTP methods that are | ||||
| 	 * allowed. | ||||
| 	 * @since 5.1 | ||||
| 	 * @see #setUnsafeAllowAnyHttpMethod(boolean) | ||||
| 	 */ | ||||
| 	public void setAllowedHttpMethods(Collection<HttpMethod> allowedHttpMethods) { | ||||
| 		Assert.notNull(allowedHttpMethods, "allowedHttpMethods cannot be null"); | ||||
| 		this.allowedHttpMethods = (allowedHttpMethods != ALLOW_ANY_HTTP_METHOD) ? new HashSet<>(allowedHttpMethods) | ||||
| 				: ALLOW_ANY_HTTP_METHOD; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if semicolon is allowed in the URL (i.e. matrix variables). The default | ||||
| 	 * is to disable this behavior because it is a common way of attempting to perform | ||||
| 	 * <a href="https://www.owasp.org/index.php/Reflected_File_Download">Reflected File | ||||
| 	 * Download Attacks</a>. It is also the source of many exploits which bypass URL based | ||||
| 	 * security. | ||||
| 	 * </p> | ||||
| 	 * <p> | ||||
| 	 * For example, the following CVEs are a subset of the issues related to ambiguities | ||||
| 	 * in the Servlet Specification on how to treat semicolons that led to CVEs: | ||||
| 	 * </p> | ||||
| 	 * <ul> | ||||
| 	 * <li><a href="https://pivotal.io/security/cve-2016-5007">cve-2016-5007</a></li> | ||||
| 	 * <li><a href="https://pivotal.io/security/cve-2016-9879">cve-2016-9879</a></li> | ||||
| 	 * <li><a href="https://pivotal.io/security/cve-2018-1199">cve-2018-1199</a></li> | ||||
| 	 * </ul> | ||||
| 	 * | ||||
| 	 * <p> | ||||
| 	 * If you are wanting to allow semicolons, please reconsider as it is a very common | ||||
| 	 * source of security bypasses. A few common reasons users want semicolons and | ||||
| 	 * alternatives are listed below: | ||||
| 	 * </p> | ||||
| 	 * <ul> | ||||
| 	 * <li>Including the JSESSIONID in the path - You should not include session id (or | ||||
| 	 * any sensitive information) in a URL as it can lead to leaking. Instead use Cookies. | ||||
| 	 * </li> | ||||
| 	 * <li>Matrix Variables - Users wanting to leverage Matrix Variables should consider | ||||
| 	 * using HTTP parameters instead.</li> | ||||
| 	 * </ul> | ||||
| 	 * @param allowSemicolon should semicolons be allowed in the URL. Default is false | ||||
| 	 */ | ||||
| 	public void setAllowSemicolon(boolean allowSemicolon) { | ||||
| 		if (allowSemicolon) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_SEMICOLON); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_SEMICOLON); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if a slash "/" that is URL encoded "%2F" should be allowed in the path | ||||
| 	 * or not. The default is to not allow this behavior because it is a common way to | ||||
| 	 * bypass URL based security. | ||||
| 	 * </p> | ||||
| 	 * <p> | ||||
| 	 * For example, due to ambiguities in the servlet specification, the value is not | ||||
| 	 * parsed consistently which results in different values in {@code HttpServletRequest} | ||||
| 	 * path related values which allow bypassing certain security constraints. | ||||
| 	 * </p> | ||||
| 	 * @param allowUrlEncodedSlash should a slash "/" that is URL encoded "%2F" be allowed | ||||
| 	 * in the path or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedSlash(boolean allowUrlEncodedSlash) { | ||||
| 		if (allowUrlEncodedSlash) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_FORWARDSLASH); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_FORWARDSLASH); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if double slash "//" that is URL encoded "%2F%2F" should be allowed in | ||||
| 	 * the path or not. The default is to not allow. | ||||
| 	 * </p> | ||||
| 	 * @param allowUrlEncodedDoubleSlash should a slash "//" that is URL encoded "%2F%2F" | ||||
| 	 * be allowed in the path or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedDoubleSlash(boolean allowUrlEncodedDoubleSlash) { | ||||
| 		if (allowUrlEncodedDoubleSlash) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_DOUBLE_FORWARDSLASH); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if a period "." that is URL encoded "%2E" should be allowed in the path | ||||
| 	 * or not. The default is to not allow this behavior because it is a frequent source | ||||
| 	 * of security exploits. | ||||
| 	 * </p> | ||||
| 	 * <p> | ||||
| 	 * For example, due to ambiguities in the servlet specification a URL encoded period | ||||
| 	 * might lead to bypassing security constraints through a directory traversal attack. | ||||
| 	 * This is because the path is not parsed consistently which results in different | ||||
| 	 * values in {@code HttpServletRequest} path related values which allow bypassing | ||||
| 	 * certain security constraints. | ||||
| 	 * </p> | ||||
| 	 * @param allowUrlEncodedPeriod should a period "." that is URL encoded "%2E" be | ||||
| 	 * allowed in the path or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) { | ||||
| 		if (allowUrlEncodedPeriod) { | ||||
| 			this.encodedUrlBlocklist.removeAll(FORBIDDEN_ENCODED_PERIOD); | ||||
| 		} | ||||
| 		else { | ||||
| 			this.encodedUrlBlocklist.addAll(FORBIDDEN_ENCODED_PERIOD); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if a backslash "\" or a URL encoded backslash "%5C" should be allowed in | ||||
| 	 * the path or not. The default is not to allow this behavior because it is a frequent | ||||
| 	 * source of security exploits. | ||||
| 	 * </p> | ||||
| 	 * <p> | ||||
| 	 * For example, due to ambiguities in the servlet specification a URL encoded period | ||||
| 	 * might lead to bypassing security constraints through a directory traversal attack. | ||||
| 	 * This is because the path is not parsed consistently which results in different | ||||
| 	 * values in {@code HttpServletRequest} path related values which allow bypassing | ||||
| 	 * certain security constraints. | ||||
| 	 * </p> | ||||
| 	 * @param allowBackSlash a backslash "\" or a URL encoded backslash "%5C" be allowed | ||||
| 	 * in the path or not. Default is false | ||||
| 	 */ | ||||
| 	public void setAllowBackSlash(boolean allowBackSlash) { | ||||
| 		if (allowBackSlash) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_BACKSLASH); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_BACKSLASH); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if a null "\0" or a URL encoded nul "%00" should be allowed in the path | ||||
| 	 * or not. The default is not to allow this behavior because it is a frequent source | ||||
| 	 * of security exploits. | ||||
| 	 * </p> | ||||
| 	 * @param allowNull a null "\0" or a URL encoded null "%00" be allowed in the path or | ||||
| 	 * not. Default is false | ||||
| 	 * @since 5.4 | ||||
| 	 */ | ||||
| 	public void setAllowNull(boolean allowNull) { | ||||
| 		if (allowNull) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_NULL); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_NULL); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines if a percent "%" that is URL encoded "%25" should be allowed in the path | ||||
| 	 * or not. The default is not to allow this behavior because it is a frequent source | ||||
| 	 * of security exploits. | ||||
| 	 * </p> | ||||
| 	 * <p> | ||||
| 	 * For example, this can lead to exploits that involve double URL encoding that lead | ||||
| 	 * to bypassing security constraints. | ||||
| 	 * </p> | ||||
| 	 * @param allowUrlEncodedPercent if a percent "%" that is URL encoded "%25" should be | ||||
| 	 * allowed in the path or not. Default is false | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) { | ||||
| 		if (allowUrlEncodedPercent) { | ||||
| 			this.encodedUrlBlocklist.remove(ENCODED_PERCENT); | ||||
| 			this.decodedUrlBlocklist.remove(PERCENT); | ||||
| 		} | ||||
| 		else { | ||||
| 			this.encodedUrlBlocklist.add(ENCODED_PERCENT); | ||||
| 			this.decodedUrlBlocklist.add(PERCENT); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Determines if a URL encoded Carriage Return is allowed in the path or not. The | ||||
| 	 * default is not to allow this behavior because it is a frequent source of security | ||||
| 	 * exploits. | ||||
| 	 * @param allowUrlEncodedCarriageReturn if URL encoded Carriage Return is allowed in | ||||
| 	 * the URL or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedCarriageReturn(boolean allowUrlEncodedCarriageReturn) { | ||||
| 		if (allowUrlEncodedCarriageReturn) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_CR); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_CR); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Determines if a URL encoded Line Feed is allowed in the path or not. The default is | ||||
| 	 * not to allow this behavior because it is a frequent source of security exploits. | ||||
| 	 * @param allowUrlEncodedLineFeed if URL encoded Line Feed is allowed in the URL or | ||||
| 	 * not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedLineFeed(boolean allowUrlEncodedLineFeed) { | ||||
| 		if (allowUrlEncodedLineFeed) { | ||||
| 			urlBlocklistsRemoveAll(FORBIDDEN_LF); | ||||
| 		} | ||||
| 		else { | ||||
| 			urlBlocklistsAddAll(FORBIDDEN_LF); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Determines if a URL encoded paragraph separator is allowed in the path or not. The | ||||
| 	 * default is not to allow this behavior because it is a frequent source of security | ||||
| 	 * exploits. | ||||
| 	 * @param allowUrlEncodedParagraphSeparator if URL encoded paragraph separator is | ||||
| 	 * allowed in the URL or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedParagraphSeparator(boolean allowUrlEncodedParagraphSeparator) { | ||||
| 		if (allowUrlEncodedParagraphSeparator) { | ||||
| 			this.decodedUrlBlocklist.removeAll(FORBIDDEN_PARAGRAPH_SEPARATOR); | ||||
| 		} | ||||
| 		else { | ||||
| 			this.decodedUrlBlocklist.addAll(FORBIDDEN_PARAGRAPH_SEPARATOR); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Determines if a URL encoded line separator is allowed in the path or not. The | ||||
| 	 * default is not to allow this behavior because it is a frequent source of security | ||||
| 	 * exploits. | ||||
| 	 * @param allowUrlEncodedLineSeparator if URL encoded line separator is allowed in the | ||||
| 	 * URL or not. Default is false. | ||||
| 	 */ | ||||
| 	public void setAllowUrlEncodedLineSeparator(boolean allowUrlEncodedLineSeparator) { | ||||
| 		if (allowUrlEncodedLineSeparator) { | ||||
| 			this.decodedUrlBlocklist.removeAll(FORBIDDEN_LINE_SEPARATOR); | ||||
| 		} | ||||
| 		else { | ||||
| 			this.decodedUrlBlocklist.addAll(FORBIDDEN_LINE_SEPARATOR); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines which header names should be allowed. The default is to reject header | ||||
| 	 * names that contain ISO control characters and characters that are not defined. | ||||
| 	 * </p> | ||||
| 	 * @param allowedHeaderNames the predicate for testing header names | ||||
| 	 * @since 5.4 | ||||
| 	 * @see Character#isISOControl(int) | ||||
| 	 * @see Character#isDefined(int) | ||||
| 	 */ | ||||
| 	public void setAllowedHeaderNames(Predicate<String> allowedHeaderNames) { | ||||
| 		Assert.notNull(allowedHeaderNames, "allowedHeaderNames cannot be null"); | ||||
| 		this.allowedHeaderNames = allowedHeaderNames; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines which header values should be allowed. The default is to reject header | ||||
| 	 * values that contain ISO control characters and characters that are not defined. | ||||
| 	 * </p> | ||||
| 	 * @param allowedHeaderValues the predicate for testing hostnames | ||||
| 	 * @since 5.4 | ||||
| 	 * @see Character#isISOControl(int) | ||||
| 	 * @see Character#isDefined(int) | ||||
| 	 */ | ||||
| 	public void setAllowedHeaderValues(Predicate<String> allowedHeaderValues) { | ||||
| 		Assert.notNull(allowedHeaderValues, "allowedHeaderValues cannot be null"); | ||||
| 		this.allowedHeaderValues = allowedHeaderValues; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Determines which parameter names should be allowed. The default is to reject header | ||||
| 	 * names that contain ISO control characters and characters that are not defined. | ||||
| 	 * @param allowedParameterNames the predicate for testing parameter names | ||||
| 	 * @since 5.4 | ||||
| 	 * @see Character#isISOControl(int) | ||||
| 	 * @see Character#isDefined(int) | ||||
| 	 */ | ||||
| 	public void setAllowedParameterNames(Predicate<String> allowedParameterNames) { | ||||
| 		Assert.notNull(allowedParameterNames, "allowedParameterNames cannot be null"); | ||||
| 		this.allowedParameterNames = allowedParameterNames; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines which parameter values should be allowed. The default is to allow any | ||||
| 	 * parameter value. | ||||
| 	 * </p> | ||||
| 	 * @param allowedParameterValues the predicate for testing parameter values | ||||
| 	 * @since 5.4 | ||||
| 	 */ | ||||
| 	public void setAllowedParameterValues(Predicate<String> allowedParameterValues) { | ||||
| 		Assert.notNull(allowedParameterValues, "allowedParameterValues cannot be null"); | ||||
| 		this.allowedParameterValues = allowedParameterValues; | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * <p> | ||||
| 	 * Determines which hostnames should be allowed. The default is to allow any hostname. | ||||
| 	 * </p> | ||||
| 	 * @param allowedHostnames the predicate for testing hostnames | ||||
| 	 * @since 5.2 | ||||
| 	 */ | ||||
| 	public void setAllowedHostnames(Predicate<String> allowedHostnames) { | ||||
| 		Assert.notNull(allowedHostnames, "allowedHostnames cannot be null"); | ||||
| 		this.allowedHostnames = allowedHostnames; | ||||
| 	} | ||||
| 
 | ||||
| 	private void urlBlocklistsAddAll(Collection<String> values) { | ||||
| 		this.encodedUrlBlocklist.addAll(values); | ||||
| 		this.decodedUrlBlocklist.addAll(values); | ||||
| 	} | ||||
| 
 | ||||
| 	private void urlBlocklistsRemoveAll(Collection<String> values) { | ||||
| 		this.encodedUrlBlocklist.removeAll(values); | ||||
| 		this.decodedUrlBlocklist.removeAll(values); | ||||
| 	} | ||||
| 
 | ||||
| 	private void rejectNonPrintableAsciiCharactersInFieldName(String toCheck, String propertyName) { | ||||
| 		if (!containsOnlyPrintableAsciiCharacters(toCheck)) { | ||||
| 			throw new ServerExchangeRejectedException(String | ||||
| 				.format("The %s was rejected because it can only contain printable ASCII characters.", propertyName)); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void rejectForbiddenHttpMethod(ServerHttpRequest request) { | ||||
| 		if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) { | ||||
| 			return; | ||||
| 		} | ||||
| 		if (!this.allowedHttpMethods.contains(request.getMethod())) { | ||||
| 			throw new ServerExchangeRejectedException( | ||||
| 					"The request was rejected because the HTTP method \"" + request.getMethod() | ||||
| 							+ "\" was not included within the list of allowed HTTP methods " + this.allowedHttpMethods); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void rejectedBlocklistedUrls(ServerHttpRequest request) { | ||||
| 		for (String forbidden : this.encodedUrlBlocklist) { | ||||
| 			if (encodedUrlContains(request, forbidden)) { | ||||
| 				throw new ServerExchangeRejectedException( | ||||
| 						"The request was rejected because the URL contained a potentially malicious String \"" | ||||
| 								+ forbidden + "\""); | ||||
| 			} | ||||
| 		} | ||||
| 		for (String forbidden : this.decodedUrlBlocklist) { | ||||
| 			if (decodedUrlContains(request, forbidden)) { | ||||
| 				throw new ServerExchangeRejectedException( | ||||
| 						"The request was rejected because the URL contained a potentially malicious String \"" | ||||
| 								+ forbidden + "\""); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void rejectedUntrustedHosts(ServerHttpRequest request) { | ||||
| 		String hostName = request.getURI().getHost(); | ||||
| 		if (hostName != null && !this.allowedHostnames.test(hostName)) { | ||||
| 			throw new ServerExchangeRejectedException( | ||||
| 					"The request was rejected because the domain " + hostName + " is untrusted."); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private static Set<HttpMethod> createDefaultAllowedHttpMethods() { | ||||
| 		Set<HttpMethod> result = new HashSet<>(); | ||||
| 		result.add(HttpMethod.DELETE); | ||||
| 		result.add(HttpMethod.GET); | ||||
| 		result.add(HttpMethod.HEAD); | ||||
| 		result.add(HttpMethod.OPTIONS); | ||||
| 		result.add(HttpMethod.PATCH); | ||||
| 		result.add(HttpMethod.POST); | ||||
| 		result.add(HttpMethod.PUT); | ||||
| 		return result; | ||||
| 	} | ||||
| 
 | ||||
| 	private boolean isNormalized(ServerHttpRequest request) { | ||||
| 		if (!isNormalized(request.getPath().value())) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		if (!isNormalized(request.getURI().getRawPath())) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		if (!isNormalized(request.getURI().getPath())) { | ||||
| 			return false; | ||||
| 		} | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	private void validateAllowedHeaderName(String headerNames) { | ||||
| 		if (!StrictServerWebExchangeFirewall.this.allowedHeaderNames.test(headerNames)) { | ||||
| 			throw new ServerExchangeRejectedException( | ||||
| 					"The request was rejected because the header name \"" + headerNames + "\" is not allowed."); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void validateAllowedHeaderValue(Object key, String value) { | ||||
| 		if (!StrictServerWebExchangeFirewall.this.allowedHeaderValues.test(value)) { | ||||
| 			throw new ServerExchangeRejectedException("The request was rejected because the header: \"" + key | ||||
| 					+ " \" has a value \"" + value + "\" that is not allowed."); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void validateAllowedParameterName(String name) { | ||||
| 		if (!StrictServerWebExchangeFirewall.this.allowedParameterNames.test(name)) { | ||||
| 			throw new ServerExchangeRejectedException( | ||||
| 					"The request was rejected because the parameter name \"" + name + "\" is not allowed."); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private void validateAllowedParameterValue(String name, String value) { | ||||
| 		if (!StrictServerWebExchangeFirewall.this.allowedParameterValues.test(value)) { | ||||
| 			throw new ServerExchangeRejectedException("The request was rejected because the parameter: \"" + name | ||||
| 					+ " \" has a value \"" + value + "\" that is not allowed."); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private static boolean encodedUrlContains(ServerHttpRequest request, String value) { | ||||
| 		if (valueContains(request.getPath().value(), value)) { | ||||
| 			return true; | ||||
| 		} | ||||
| 		return valueContains(request.getURI().getRawPath(), value); | ||||
| 	} | ||||
| 
 | ||||
| 	private static boolean decodedUrlContains(ServerHttpRequest request, String value) { | ||||
| 		return valueContains(request.getURI().getPath(), value); | ||||
| 	} | ||||
| 
 | ||||
| 	private static boolean containsOnlyPrintableAsciiCharacters(String uri) { | ||||
| 		if (uri == null) { | ||||
| 			return true; | ||||
| 		} | ||||
| 		int length = uri.length(); | ||||
| 		for (int i = 0; i < length; i++) { | ||||
| 			char ch = uri.charAt(i); | ||||
| 			if (ch < '\u0020' || ch > '\u007e') { | ||||
| 				return false; | ||||
| 			} | ||||
| 		} | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	private static boolean valueContains(String value, String contains) { | ||||
| 		return value != null && value.contains(contains); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * Checks whether a path is normalized (doesn't contain path traversal sequences like | ||||
| 	 * "./", "/../" or "/.") | ||||
| 	 * @param path the path to test | ||||
| 	 * @return true if the path doesn't contain any path-traversal character sequences. | ||||
| 	 */ | ||||
| 	private static boolean isNormalized(String path) { | ||||
| 		if (path == null) { | ||||
| 			return true; | ||||
| 		} | ||||
| 		for (int i = path.length(); i > 0;) { | ||||
| 			int slashIndex = path.lastIndexOf('/', i - 1); | ||||
| 			int gap = i - slashIndex; | ||||
| 			if (gap == 2 && path.charAt(slashIndex + 1) == '.') { | ||||
| 				return false; // ".", "/./" or "/." | ||||
| 			} | ||||
| 			if (gap == 3 && path.charAt(slashIndex + 1) == '.' && path.charAt(slashIndex + 2) == '.') { | ||||
| 				return false; | ||||
| 			} | ||||
| 			i = slashIndex; | ||||
| 		} | ||||
| 		return true; | ||||
| 	} | ||||
| 
 | ||||
| 	private final class StrictFirewallServerWebExchange extends ServerWebExchangeDecorator { | ||||
| 
 | ||||
| 		private StrictFirewallServerWebExchange(ServerWebExchange delegate) { | ||||
| 			super(delegate); | ||||
| 		} | ||||
| 
 | ||||
| 		@Override | ||||
| 		public ServerHttpRequest getRequest() { | ||||
| 			return new StrictFirewallHttpRequest(super.getRequest()); | ||||
| 		} | ||||
| 
 | ||||
| 		private final class StrictFirewallHttpRequest extends ServerHttpRequestDecorator { | ||||
| 
 | ||||
| 			private StrictFirewallHttpRequest(ServerHttpRequest delegate) { | ||||
| 				super(delegate); | ||||
| 			} | ||||
| 
 | ||||
| 			@Override | ||||
| 			public HttpHeaders getHeaders() { | ||||
| 				return new StrictFirewallHttpHeaders(super.getHeaders()); | ||||
| 			} | ||||
| 
 | ||||
| 			@Override | ||||
| 			public MultiValueMap<String, String> getQueryParams() { | ||||
| 				MultiValueMap<String, String> queryParams = super.getQueryParams(); | ||||
| 				for (Map.Entry<String, List<String>> paramEntry : queryParams.entrySet()) { | ||||
| 					String paramName = paramEntry.getKey(); | ||||
| 					validateAllowedParameterName(paramName); | ||||
| 					for (String paramValue : paramEntry.getValue()) { | ||||
| 						validateAllowedParameterValue(paramName, paramValue); | ||||
| 					} | ||||
| 				} | ||||
| 				return queryParams; | ||||
| 			} | ||||
| 
 | ||||
| 			private final class StrictFirewallHttpHeaders extends HttpHeaders { | ||||
| 
 | ||||
| 				private StrictFirewallHttpHeaders(HttpHeaders delegate) { | ||||
| 					super(delegate); | ||||
| 				} | ||||
| 
 | ||||
| 				@Override | ||||
| 				public String getFirst(String headerName) { | ||||
| 					validateAllowedHeaderName(headerName); | ||||
| 					String headerValue = super.getFirst(headerName); | ||||
| 					validateAllowedHeaderValue(headerName, headerValue); | ||||
| 					return headerValue; | ||||
| 				} | ||||
| 
 | ||||
| 				@Override | ||||
| 				public List<String> get(Object key) { | ||||
| 					if (key instanceof String headerName) { | ||||
| 						validateAllowedHeaderName(headerName); | ||||
| 					} | ||||
| 					List<String> headerValues = super.get(key); | ||||
| 					if (headerValues == null) { | ||||
| 						return headerValues; | ||||
| 					} | ||||
| 					for (String headerValue : headerValues) { | ||||
| 						validateAllowedHeaderValue(key, headerValue); | ||||
| 					} | ||||
| 					return headerValues; | ||||
| 				} | ||||
| 
 | ||||
| 				@Override | ||||
| 				public Set<String> keySet() { | ||||
| 					Set<String> headerNames = super.keySet(); | ||||
| 					for (String headerName : headerNames) { | ||||
| 						validateAllowedHeaderName(headerName); | ||||
| 					} | ||||
| 					return headerNames; | ||||
| 				} | ||||
| 
 | ||||
| 			} | ||||
| 
 | ||||
| 		} | ||||
| 
 | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
| @ -27,12 +27,16 @@ import org.junit.jupiter.api.Test; | ||||
| import org.mockito.ArgumentCaptor; | ||||
| import reactor.core.publisher.Mono; | ||||
| 
 | ||||
| import org.springframework.http.HttpMethod; | ||||
| import org.springframework.http.HttpStatus; | ||||
| import org.springframework.mock.http.server.reactive.MockServerHttpRequest; | ||||
| import org.springframework.mock.web.server.MockServerWebExchange; | ||||
| import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterChainObservationContext; | ||||
| import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterChainObservationConvention; | ||||
| import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterObservation; | ||||
| import org.springframework.security.web.server.firewall.ServerExchangeRejectedException; | ||||
| import org.springframework.security.web.server.firewall.ServerExchangeRejectedHandler; | ||||
| import org.springframework.security.web.server.firewall.ServerWebExchangeFirewall; | ||||
| import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; | ||||
| import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; | ||||
| import org.springframework.test.web.reactive.server.WebTestClient; | ||||
| @ -48,6 +52,7 @@ import static org.mockito.Mockito.atLeastOnce; | ||||
| import static org.mockito.Mockito.mock; | ||||
| import static org.mockito.Mockito.times; | ||||
| import static org.mockito.Mockito.verify; | ||||
| import static org.mockito.Mockito.verifyNoInteractions; | ||||
| 
 | ||||
| /** | ||||
|  * @author Rob Winch | ||||
| @ -143,6 +148,77 @@ public class WebFilterChainProxyTests { | ||||
| 		assertFilterChainObservation(contexts.next(), "before", 1); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void doFilterWhenFirewallThenBadRequest() { | ||||
| 		List<WebFilter> filters = Arrays.asList(new Http200WebFilter()); | ||||
| 		ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch(); | ||||
| 		MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters); | ||||
| 		WebFilterChainProxy filter = new WebFilterChainProxy(chain); | ||||
| 		WebTestClient.bindToController(new Object()) | ||||
| 			.webFilter(filter) | ||||
| 			.build() | ||||
| 			.method(HttpMethod.valueOf("INVALID")) | ||||
| 			.exchange() | ||||
| 			.expectStatus() | ||||
| 			.isBadRequest(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void doFilterWhenCustomFirewallThenInvoked() { | ||||
| 		List<WebFilter> filters = Arrays.asList(new Http200WebFilter()); | ||||
| 		ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch(); | ||||
| 		MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters); | ||||
| 		WebFilterChainProxy filter = new WebFilterChainProxy(chain); | ||||
| 		ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class); | ||||
| 		ServerWebExchangeFirewall firewall = mock(ServerWebExchangeFirewall.class); | ||||
| 		filter.setFirewall(firewall); | ||||
| 		filter.setExchangeRejectedHandler(handler); | ||||
| 		WebTestClient.bindToController(new Object()).webFilter(filter).build().get().exchange(); | ||||
| 		verify(firewall).getFirewalledExchange(any()); | ||||
| 		verifyNoInteractions(handler); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void doFilterWhenCustomExchangeRejectedHandlerThenInvoked() { | ||||
| 		List<WebFilter> filters = Arrays.asList(new Http200WebFilter()); | ||||
| 		ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch(); | ||||
| 		MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters); | ||||
| 		WebFilterChainProxy filter = new WebFilterChainProxy(chain); | ||||
| 		ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class); | ||||
| 		ServerWebExchangeFirewall firewall = mock(ServerWebExchangeFirewall.class); | ||||
| 		given(firewall.getFirewalledExchange(any())) | ||||
| 			.willReturn(Mono.error(new ServerExchangeRejectedException("Oops"))); | ||||
| 		filter.setFirewall(firewall); | ||||
| 		filter.setExchangeRejectedHandler(handler); | ||||
| 		WebTestClient.bindToController(new Object()).webFilter(filter).build().get().exchange(); | ||||
| 		verify(firewall).getFirewalledExchange(any()); | ||||
| 		verify(handler).handle(any(), any()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void doFilterWhenDelayedServerExchangeRejectedException() { | ||||
| 		List<WebFilter> filters = Arrays.asList(new WebFilter() { | ||||
| 			@Override | ||||
| 			public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { | ||||
| 				// simulate a delayed error (e.g. reading parameters) | ||||
| 				return Mono.error(new ServerExchangeRejectedException("Ooops")); | ||||
| 			} | ||||
| 		}); | ||||
| 		ServerWebExchangeMatcher match = (exchange) -> MatchResult.match(); | ||||
| 		MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(match, filters); | ||||
| 		WebFilterChainProxy filter = new WebFilterChainProxy(chain); | ||||
| 		ServerExchangeRejectedHandler handler = mock(ServerExchangeRejectedHandler.class); | ||||
| 		filter.setExchangeRejectedHandler(handler); | ||||
| 		// @formatter:off | ||||
| 		WebTestClient.bindToController(new Object()) | ||||
| 			.webFilter(filter) | ||||
| 			.build() | ||||
| 			.get() | ||||
| 			.exchange(); | ||||
| 		// @formatter:on | ||||
| 		verify(handler).handle(any(), any()); | ||||
| 	} | ||||
| 
 | ||||
| 	static void assertFilterChainObservation(Observation.Context context, String filterSection, int chainPosition) { | ||||
| 		assertThat(context).isInstanceOf(WebFilterChainObservationContext.class); | ||||
| 		WebFilterChainObservationContext filterChainObservationContext = (WebFilterChainObservationContext) context; | ||||
|  | ||||
| @ -0,0 +1,516 @@ | ||||
| /* | ||||
|  * Copyright 2002-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. | ||||
|  */ | ||||
| 
 | ||||
| package org.springframework.security.web.server.firewall; | ||||
| 
 | ||||
| import java.net.URI; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import org.junit.jupiter.api.Test; | ||||
| 
 | ||||
| import org.springframework.http.HttpHeaders; | ||||
| import org.springframework.http.HttpMethod; | ||||
| import org.springframework.http.ResponseCookie; | ||||
| import org.springframework.http.server.reactive.ServerHttpRequest; | ||||
| import org.springframework.mock.http.server.reactive.MockServerHttpRequest; | ||||
| import org.springframework.mock.web.server.MockServerWebExchange; | ||||
| import org.springframework.web.server.ServerWebExchange; | ||||
| 
 | ||||
| import static org.assertj.core.api.Assertions.assertThat; | ||||
| import static org.assertj.core.api.Assertions.assertThatExceptionOfType; | ||||
| import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; | ||||
| 
 | ||||
| /** | ||||
|  * Tests for {@link StrictServerWebExchangeFirewall}. | ||||
|  * | ||||
|  * @author Rob Winch | ||||
|  * @since 6.4 | ||||
|  */ | ||||
| class StrictServerWebExchangeFirewallTests { | ||||
| 
 | ||||
| 	public String[] unnormalizedPaths = { "http://exploit.example/..", "http://exploit.example/./path/", | ||||
| 			"http://exploit.example/path/path/.", "http://exploit.example/path/path//.", | ||||
| 			"http://exploit.example/./path/../path//.", "http://exploit.example/./path", | ||||
| 			"http://exploit.example/.//path", "http://exploit.example/.", "http://exploit.example//path", | ||||
| 			"http://exploit.example//path/path", "http://exploit.example//path//path", | ||||
| 			"http://exploit.example/path//path" }; | ||||
| 
 | ||||
| 	private StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); | ||||
| 
 | ||||
| 	private MockServerHttpRequest.BaseBuilder<?> request = get("/"); | ||||
| 
 | ||||
| 	@Test | ||||
| 	void cookieWhenHasNewLineThenThrowsException() { | ||||
| 		assertThatIllegalArgumentException() | ||||
| 			.isThrownBy(() -> ResponseCookie.from("test").value("Something\nhere").build()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void cookieWhenHasLineFeedThenThrowsException() { | ||||
| 		assertThatIllegalArgumentException() | ||||
| 			.isThrownBy(() -> ResponseCookie.from("test").value("Something\rhere").build()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void responseHeadersWhenValueHasNewLineThenThrowsException() { | ||||
| 		this.request = MockServerHttpRequest.get("/"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		exchange.getResponse().getHeaders().set("FOO", "new\nline"); | ||||
| 		assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void responseHeadersWhenValueHasLineFeedThenThrowsException() { | ||||
| 		this.request = MockServerHttpRequest.get("/"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		exchange.getResponse().getHeaders().set("FOO", "line\rfeed"); | ||||
| 		assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void responseHeadersWhenNameHasNewLineThenThrowsException() { | ||||
| 		this.request = MockServerHttpRequest.get("/"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		exchange.getResponse().getHeaders().set("new\nline", "FOO"); | ||||
| 		assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void responseHeadersWhenNameHasLineFeedThenThrowsException() { | ||||
| 		this.request = MockServerHttpRequest.get("/"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		exchange.getResponse().getHeaders().set("line\rfeed", "FOO"); | ||||
| 		assertThatIllegalArgumentException().isThrownBy(() -> exchange.getResponse().setComplete().block()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenInvalidMethodThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.valueOf("INVALID"), "/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	private ServerWebExchange getFirewalledExchange() { | ||||
| 		MockServerWebExchange exchange = MockServerWebExchange.from(this.request.build()); | ||||
| 		return this.firewall.getFirewalledExchange(exchange).block(); | ||||
| 	} | ||||
| 
 | ||||
| 	private MockServerHttpRequest.BodyBuilder get(String uri) { | ||||
| 		URI url = URI.create(uri); | ||||
| 		return MockServerHttpRequest.method(HttpMethod.GET, url); | ||||
| 	} | ||||
| 
 | ||||
| 	// blocks XST attacks | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenTraceMethodThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.TRACE, "/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	// blocks XST attack if request is forwarded to a Microsoft IIS web server | ||||
| 	void getFirewalledExchangeWhenTrackMethodThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.TRACE, "/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	// HTTP methods are case sensitive | ||||
| 	void getFirewalledExchangeWhenLowercaseGetThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.valueOf("get"), "/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowedThenNoException() { | ||||
| 		List<String> allowedMethods = Arrays.asList("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"); | ||||
| 		for (String allowedMethod : allowedMethods) { | ||||
| 			this.request = MockServerHttpRequest.method(HttpMethod.valueOf(allowedMethod), "/"); | ||||
| 			getFirewalledExchange(); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenInvalidMethodAndAnyMethodThenNoException() { | ||||
| 		this.firewall.setUnsafeAllowAnyHttpMethod(true); | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.valueOf("INVALID"), "/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenURINotNormalizedThenThrowsServerExchangeRejectedException() { | ||||
| 		for (String path : this.unnormalizedPaths) { | ||||
| 			this.request = get(path); | ||||
| 			assertThatExceptionOfType(ServerExchangeRejectedException.class) | ||||
| 				.describedAs("The path '" + path + "' is not normalized") | ||||
| 				.isThrownBy(() -> getFirewalledExchange()); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenSemicolonInRequestUriThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = get("/path;/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenEncodedSemicolonInRequestUriThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = get("/path%3B/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenLowercaseEncodedSemicolonInRequestUriThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = MockServerHttpRequest.method(HttpMethod.GET, URI.create("/path%3b/")); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenSemicolonInRequestUriAndAllowSemicolonThenNoException() { | ||||
| 		this.firewall.setAllowSemicolon(true); | ||||
| 		this.request = get("/path;/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenEncodedSemicolonInRequestUriAndAllowSemicolonThenNoException() { | ||||
| 		this.firewall.setAllowSemicolon(true); | ||||
| 		this.request = get("/path%3B/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenLowercaseEncodedSemicolonInRequestUriAndAllowSemicolonThenNoException() { | ||||
| 		this.firewall.setAllowSemicolon(true); | ||||
| 		this.request = get("/path%3b/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenLowercaseEncodedPeriodInThenThrowsServerExchangeRejectedException() { | ||||
| 		this.request = get("/%2e/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsLowerboundAsciiThenNoException() { | ||||
| 		this.request = get("/%20"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsUpperboundAsciiThenNoException() { | ||||
| 		this.request = get("/~"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenJapaneseCharacterThenNoException() { | ||||
| 		// FIXME: .method(HttpMethod.GET to .get and similar methods | ||||
| 		this.request = get("/\u3042"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsEncodedNullThenException() { | ||||
| 		this.request = get("/something%00/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsLowercaseEncodedLineFeedThenException() { | ||||
| 		this.request = get("/something%0a/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsUppercaseEncodedLineFeedThenException() { | ||||
| 		this.request = get("/something%0A/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsLowercaseEncodedCarriageReturnThenException() { | ||||
| 		this.request = get("/something%0d/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsUppercaseEncodedCarriageReturnThenException() { | ||||
| 		this.request = get("/something%0D/"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsLowercaseEncodedLineFeedAndAllowedThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedLineFeed(true); | ||||
| 		this.request = get("/something%0a/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsUppercaseEncodedLineFeedAndAllowedThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedLineFeed(true); | ||||
| 		this.request = get("/something%0A/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsLowercaseEncodedCarriageReturnAndAllowedThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedCarriageReturn(true); | ||||
| 		this.request = get("/something%0d/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenContainsUppercaseEncodedCarriageReturnAndAllowedThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedCarriageReturn(true); | ||||
| 		this.request = get("/something%0D/"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	/** | ||||
| 	 * On WebSphere 8.5 a URL like /context-root/a/b;%2f1/c can bypass a rule on /a/b/c | ||||
| 	 * because the pathInfo is /a/b;/1/c which ends up being /a/b/1/c while Spring MVC | ||||
| 	 * will strip the ; content from requestURI before the path is URL decoded. | ||||
| 	 */ | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenLowercaseEncodedPathThenException() { | ||||
| 		this.request = get("/context-root/a/b;%2f1/c"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenUppercaseEncodedPathThenException() { | ||||
| 		this.request = get("/context-root/a/b;%2F1/c"); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlEncodedSlashAndLowercaseEncodedPathThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowSemicolon(true); | ||||
| 		this.request = get("/context-root/a/b;%2f1/c"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlEncodedSlashAndUppercaseEncodedPathThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowSemicolon(true); | ||||
| 		this.request = get("/context-root/a/b;%2F1/c"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlLowerCaseEncodedDoubleSlashThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowUrlEncodedDoubleSlash(true); | ||||
| 		this.request = get("/context-root/a/b%2f%2fc"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlUpperCaseEncodedDoubleSlashThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowUrlEncodedDoubleSlash(true); | ||||
| 		this.request = get("/context-root/a/b%2F%2Fc"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlLowerCaseAndUpperCaseEncodedDoubleSlashThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowUrlEncodedDoubleSlash(true); | ||||
| 		this.request = get("/context-root/a/b%2f%2Fc"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenAllowUrlUpperCaseAndLowerCaseEncodedDoubleSlashThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.firewall.setAllowUrlEncodedDoubleSlash(true); | ||||
| 		this.request = get("/context-root/a/b%2F%2fc"); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenRemoveFromUpperCaseEncodedUrlBlocklistThenNoException() { | ||||
| 		this.firewall.setAllowUrlEncodedSlash(true); | ||||
| 		this.request = get("/context-root/a/b%2Fc"); | ||||
| 		this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F%2F")); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenRemoveFromDecodedUrlBlocklistThenNoException() { | ||||
| 		this.request = get("/a/b%2F%2Fc"); | ||||
| 		this.firewall.getDecodedUrlBlocklist().removeAll(Arrays.asList("//")); | ||||
| 		this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F%2F")); | ||||
| 		this.firewall.getEncodedUrlBlocklist().removeAll(Arrays.asList("%2F")); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenTrustedDomainThenNoException() { | ||||
| 		this.request.header("Host", "example.org"); | ||||
| 		this.firewall.setAllowedHostnames((hostname) -> hostname.equals("example.org")); | ||||
| 		getFirewalledExchange(); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenUntrustedDomainThenException() { | ||||
| 		this.request = get("https://example.org"); | ||||
| 		this.firewall.setAllowedHostnames((hostname) -> hostname.equals("myexample.org")); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> getFirewalledExchange()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenNotAllowedHeaderNameThenException() { | ||||
| 		this.firewall.setAllowedHeaderNames((name) -> !name.equals("bad name")); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("bad name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenHeaderNameNotAllowedWithAugmentedHeaderNamesThenException() { | ||||
| 		this.firewall.setAllowedHeaderNames( | ||||
| 				StrictServerWebExchangeFirewall.ALLOWED_HEADER_NAMES.and((name) -> !name.equals("bad name"))); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.getFirst("bad name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenNotAllowedHeaderValueThenException() { | ||||
| 		this.request.header("good name", "bad value"); | ||||
| 		this.firewall.setAllowedHeaderValues((value) -> !value.equals("bad value")); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("good name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeWhenHeaderValueNotAllowedWithAugmentedHeaderValuesThenException() { | ||||
| 		this.request.header("good name", "bad value"); | ||||
| 		this.firewall.setAllowedHeaderValues( | ||||
| 				StrictServerWebExchangeFirewall.ALLOWED_HEADER_VALUES.and((value) -> !value.equals("bad value"))); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("good name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetDateHeaderWhenControlCharacterInHeaderNameThenException() { | ||||
| 		this.request.header("Bad\0Name", "some value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\0Name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenUndefinedCharacterInHeaderNameThenException() { | ||||
| 		this.request.header("Bad\uFFFEName", "some value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\uFFFEName")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeadersWhenControlCharacterInHeaderNameThenException() { | ||||
| 		this.request.header("Bad\0Name", "some value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Bad\0Name")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderNamesWhenControlCharacterInHeaderNameThenException() { | ||||
| 		this.request.header("Bad\0Name", "some value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class) | ||||
| 			.isThrownBy(() -> headers.keySet().iterator().next()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenControlCharacterInHeaderValueThenException() { | ||||
| 		this.request.header("Something", "bad\0value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Something")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenHorizontalTabInHeaderValueThenNoException() { | ||||
| 		this.request.header("Something", "tab\tvalue"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThat(headers.getFirst("Something")).isEqualTo("tab\tvalue"); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenUndefinedCharacterInHeaderValueThenException() { | ||||
| 		this.request.header("Something", "bad\uFFFEvalue"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class) | ||||
| 			.isThrownBy(() -> headers.getFirst("Something")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeadersWhenControlCharacterInHeaderValueThenException() { | ||||
| 		this.request.header("Something", "bad\0value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		HttpHeaders headers = exchange.getRequest().getHeaders(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> headers.get("Something")); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetParameterWhenControlCharacterInParameterNameThenException() { | ||||
| 		this.request.queryParam("Bad\0Name", "some value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		ServerHttpRequest request = exchange.getRequest(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(request::getQueryParams); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetParameterValuesWhenNotAllowedInParameterValueThenException() { | ||||
| 		this.firewall.setAllowedParameterValues((value) -> !value.equals("bad value")); | ||||
| 		this.request.queryParam("Something", "bad value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		ServerHttpRequest request = exchange.getRequest(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> request.getQueryParams()); | ||||
| 	} | ||||
| 
 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetParameterValuesWhenNotAllowedInParameterNameThenException() { | ||||
| 		this.firewall.setAllowedParameterNames((value) -> !value.equals("bad name")); | ||||
| 		this.request.queryParam("bad name", "good value"); | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		ServerHttpRequest request = exchange.getRequest(); | ||||
| 		assertThatExceptionOfType(ServerExchangeRejectedException.class).isThrownBy(() -> request.getQueryParams()); | ||||
| 	} | ||||
| 
 | ||||
| 	// gh-9598 | ||||
| 	@Test | ||||
| 	void getFirewalledExchangeGetHeaderWhenNameIsNullThenNull() { | ||||
| 		ServerWebExchange exchange = getFirewalledExchange(); | ||||
| 		assertThat(exchange.getRequest().getHeaders().get(null)).isNull(); | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user