Sfoglia il codice sorgente

Merge branch '6.2.x' into 6.3.x

Add Firewall for WebFlux

Closes gh-15967
Rob Winch 10 mesi fa
parent
commit
1528c421bd

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -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 - 0
docs/modules/ROOT/pages/reactive/exploits/firewall.adoc

@@ -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()
+}
+----
+======

+ 41 - 6
web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java

@@ -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;
 	}
 
 	/**

+ 66 - 0
web/src/main/java/org/springframework/security/web/server/firewall/HttpStatusExchangeRejectedHandler.java

@@ -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);
+		});
+	}
+
+}

+ 31 - 0
web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedException.java

@@ -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);
+	}
+
+}

+ 39 - 0
web/src/main/java/org/springframework/security/web/server/firewall/ServerExchangeRejectedHandler.java

@@ -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);
+
+}

+ 46 - 0
web/src/main/java/org/springframework/security/web/server/firewall/ServerWebExchangeFirewall.java

@@ -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);
+
+}

+ 790 - 0
web/src/main/java/org/springframework/security/web/server/firewall/StrictServerWebExchangeFirewall.java

@@ -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;
+				}
+
+			}
+
+		}
+
+	}
+
+}

+ 76 - 0
web/src/test/java/org/springframework/security/web/server/WebFilterChainProxyTests.java

@@ -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;

+ 516 - 0
web/src/test/java/org/springframework/security/web/server/firewall/StrictServerWebExchangeFirewallTests.java

@@ -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();
+	}
+
+}