Skip to content

Commit

Permalink
Add Firewall for WebFlux
Browse files Browse the repository at this point in the history
Closes gh-15967
  • Loading branch information
rwinch committed Oct 21, 2024
1 parent ee5e11a commit 0e257b5
Show file tree
Hide file tree
Showing 10 changed files with 1,808 additions and 6 deletions.
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,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]
Expand Down
202 changes: 202 additions & 0 deletions docs/modules/ROOT/pages/reactive/exploits/firewall.adoc
Original file line number Diff line number Diff line change
@@ -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()
}
----
======
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -20,11 +20,15 @@
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;
Expand All @@ -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;
}
Expand All @@ -53,14 +61,41 @@ public WebFilterChainProxy(SecurityWebFilterChain... filters) {

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

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading

0 comments on commit 0e257b5

Please sign in to comment.