Skip to content

Commit 4ce7cde

Browse files
committed
Add Firewall for WebFlux
Closes gh-15967
1 parent c8342fc commit 4ce7cde

File tree

10 files changed

+1805
-3
lines changed

10 files changed

+1805
-3
lines changed

docs/modules/ROOT/nav.adoc

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
*** xref:reactive/exploits/csrf.adoc[CSRF]
148148
*** xref:reactive/exploits/headers.adoc[Headers]
149149
*** xref:reactive/exploits/http.adoc[HTTP Requests]
150+
*** xref:reactive/exploits/firewall.adoc[]
150151
** Integrations
151152
*** xref:reactive/integrations/cors.adoc[CORS]
152153
*** xref:reactive/integrations/rsocket.adoc[RSocket]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
[[webflux-serverwebexchangefirewall]]
2+
= ServerWebExchangeFirewall
3+
4+
There are various ways a request can be created by malicious users that can exploit applications.
5+
Spring Security provides the `ServerWebExchangeFirewall` to allow rejecting requests that look malicious.
6+
The default implementation is `StrictServerWebExchangeFirewall` which rejects malicious requests.
7+
8+
For example a request could contain path-traversal sequences (such as `/../`) or multiple forward slashes (`//`) that could also cause pattern-matches to fail.
9+
Some containers normalize these out before performing the servlet mapping, but others do not.
10+
To protect against issues like these, `WebFilterChainProxy` uses a `ServerWebExchangeFirewall` strategy to check and wrap the request.
11+
By default, un-normalized requests are automatically rejected, and path parameters are removed for matching purposes.
12+
(So, for example, an original request path of `/secure;hack=1/somefile.html;hack=2` is returned as `/secure/somefile.html`.)
13+
It is, therefore, essential that a `WebFilterChainProxy` is used.
14+
15+
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.
16+
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.
17+
You should restrict yourself to using a few simple patterns that are simple to understand.
18+
Always try to use a "`deny-by-default`" approach, where you have a catch-all wildcard (`/**` or `**`) defined last to deny access.
19+
20+
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.
21+
22+
You can customize the `ServerWebExchangeFirewall` by exposing it as a Bean.
23+
24+
.Allow Matrix Variables
25+
[tabs]
26+
======
27+
Java::
28+
+
29+
[source,java,role="primary"]
30+
----
31+
@Bean
32+
public StrictServerWebExchangeFirewall httpFirewall() {
33+
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
34+
firewall.setAllowSemicolon(true);
35+
return firewall;
36+
}
37+
----
38+
39+
Kotlin::
40+
+
41+
[source,kotlin,role="secondary"]
42+
----
43+
@Bean
44+
fun httpFirewall(): StrictServerWebExchangeFirewall {
45+
val firewall = StrictServerWebExchangeFirewall()
46+
firewall.setAllowSemicolon(true)
47+
return firewall
48+
}
49+
----
50+
======
51+
52+
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.
53+
The default valid methods are `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, and `PUT`.
54+
If your application needs to modify the valid methods, you can configure a custom `StrictServerWebExchangeFirewall` bean.
55+
The following example allows only HTTP `GET` and `POST` methods:
56+
57+
58+
.Allow Only GET & POST
59+
[tabs]
60+
======
61+
Java::
62+
+
63+
[source,java,role="primary"]
64+
----
65+
@Bean
66+
public StrictServerWebExchangeFirewall httpFirewall() {
67+
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
68+
firewall.setAllowedHttpMethods(Arrays.asList("GET", "POST"));
69+
return firewall;
70+
}
71+
----
72+
73+
Kotlin::
74+
+
75+
[source,kotlin,role="secondary"]
76+
----
77+
@Bean
78+
fun httpFirewall(): StrictServerWebExchangeFirewall {
79+
val firewall = StrictServerWebExchangeFirewall()
80+
firewall.setAllowedHttpMethods(listOf("GET", "POST"))
81+
return firewall
82+
}
83+
----
84+
======
85+
86+
If you must allow any HTTP method (not recommended), you can use `StrictServerWebExchangeFirewall.setUnsafeAllowAnyHttpMethod(true)`.
87+
Doing so entirely disables validation of the HTTP method.
88+
89+
90+
[[webflux-serverwebexchangefirewall-headers-parameters]]
91+
`StrictServerWebExchangeFirewall` also checks header names and values and parameter names.
92+
It requires that each character have a defined code point and not be a control character.
93+
94+
This requirement can be relaxed or adjusted as necessary by using the following methods:
95+
96+
* `StrictServerWebExchangeFirewall#setAllowedHeaderNames(Predicate)`
97+
* `StrictServerWebExchangeFirewall#setAllowedHeaderValues(Predicate)`
98+
* `StrictServerWebExchangeFirewall#setAllowedParameterNames(Predicate)`
99+
100+
[NOTE]
101+
====
102+
Parameter values can be also controlled with `setAllowedParameterValues(Predicate)`.
103+
====
104+
105+
For example, to switch off this check, you can wire your `StrictServerWebExchangeFirewall` with `Predicate` instances that always return `true`:
106+
107+
.Allow Any Header Name, Header Value, and Parameter Name
108+
[tabs]
109+
======
110+
Java::
111+
+
112+
[source,java,role="primary"]
113+
----
114+
@Bean
115+
public StrictServerWebExchangeFirewall httpFirewall() {
116+
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
117+
firewall.setAllowedHeaderNames((header) -> true);
118+
firewall.setAllowedHeaderValues((header) -> true);
119+
firewall.setAllowedParameterNames((parameter) -> true);
120+
return firewall;
121+
}
122+
----
123+
124+
Kotlin::
125+
+
126+
[source,kotlin,role="secondary"]
127+
----
128+
@Bean
129+
fun httpFirewall(): StrictServerWebExchangeFirewall {
130+
val firewall = StrictServerWebExchangeFirewall()
131+
firewall.setAllowedHeaderNames { true }
132+
firewall.setAllowedHeaderValues { true }
133+
firewall.setAllowedParameterNames { true }
134+
return firewall
135+
}
136+
----
137+
======
138+
139+
Alternatively, there might be a specific value that you need to allow.
140+
141+
For example, iPhone Xʀ uses a `User-Agent` that includes a character that is not in the ISO-8859-1 charset.
142+
Due to this fact, some application servers parse this value into two separate characters, the latter being an undefined character.
143+
144+
You can address this with the `setAllowedHeaderValues` method:
145+
146+
.Allow Certain User Agents
147+
[tabs]
148+
======
149+
Java::
150+
+
151+
[source,java,role="primary"]
152+
----
153+
@Bean
154+
public StrictServerWebExchangeFirewall httpFirewall() {
155+
StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
156+
Pattern allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
157+
Pattern userAgent = ...;
158+
firewall.setAllowedHeaderValues((header) -> allowed.matcher(header).matches() || userAgent.matcher(header).matches());
159+
return firewall;
160+
}
161+
----
162+
163+
Kotlin::
164+
+
165+
[source,kotlin,role="secondary"]
166+
----
167+
@Bean
168+
fun httpFirewall(): StrictServerWebExchangeFirewall {
169+
val firewall = StrictServerWebExchangeFirewall()
170+
val allowed = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*")
171+
val userAgent = Pattern.compile(...)
172+
firewall.setAllowedHeaderValues { allowed.matcher(it).matches() || userAgent.matcher(it).matches() }
173+
return firewall
174+
}
175+
----
176+
======
177+
178+
In the case of header values, you may instead consider parsing them as UTF-8 at verification time:
179+
180+
.Parse Headers As UTF-8
181+
[tabs]
182+
======
183+
Java::
184+
+
185+
[source,java,role="primary"]
186+
----
187+
firewall.setAllowedHeaderValues((header) -> {
188+
String parsed = new String(header.getBytes(ISO_8859_1), UTF_8);
189+
return allowed.matcher(parsed).matches();
190+
});
191+
----
192+
193+
Kotlin::
194+
+
195+
[source,kotlin,role="secondary"]
196+
----
197+
firewall.setAllowedHeaderValues {
198+
val parsed = String(header.getBytes(ISO_8859_1), UTF_8)
199+
return allowed.matcher(parsed).matches()
200+
}
201+
----
202+
======

web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java

+42-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
import reactor.core.publisher.Flux;
2323
import reactor.core.publisher.Mono;
2424

25+
import org.springframework.security.web.server.firewall.HttpStatusExchangeRejectedHandler;
26+
import org.springframework.security.web.server.firewall.ServerExchangeRejectedException;
27+
import org.springframework.security.web.server.firewall.ServerExchangeRejectedHandler;
28+
import org.springframework.security.web.server.firewall.ServerWebExchangeFirewall;
29+
import org.springframework.security.web.server.firewall.StrictServerWebExchangeFirewall;
30+
import org.springframework.util.Assert;
2531
import org.springframework.web.server.ServerWebExchange;
2632
import org.springframework.web.server.WebFilter;
2733
import org.springframework.web.server.WebFilterChain;
@@ -38,6 +44,10 @@ public class WebFilterChainProxy implements WebFilter {
3844

3945
private final List<SecurityWebFilterChain> filters;
4046

47+
private ServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall();
48+
49+
private ServerExchangeRejectedHandler exchangeRejectedHandler = new HttpStatusExchangeRejectedHandler();
50+
4151
public WebFilterChainProxy(List<SecurityWebFilterChain> filters) {
4252
this.filters = filters;
4353
}
@@ -48,12 +58,41 @@ public WebFilterChainProxy(SecurityWebFilterChain... filters) {
4858

4959
@Override
5060
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
61+
return this.firewall.getFirewalledExchange(exchange)
62+
.flatMap((firewalledExchange) -> filterFirewalledExchange(firewalledExchange, chain))
63+
.onErrorResume(ServerExchangeRejectedException.class,
64+
(rejected) -> this.exchangeRejectedHandler.handle(exchange, rejected).then(Mono.empty()));
65+
}
66+
67+
private Mono<Void> filterFirewalledExchange(ServerWebExchange firewalledExchange, WebFilterChain chain) {
5168
return Flux.fromIterable(this.filters)
52-
.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(exchange)).next()
53-
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
69+
.filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(firewalledExchange)).next()
70+
.switchIfEmpty(chain.filter(firewalledExchange).then(Mono.empty()))
5471
.flatMap((securityWebFilterChain) -> securityWebFilterChain.getWebFilters().collectList())
5572
.map((filters) -> new FilteringWebHandler(chain::filter, filters)).map(DefaultWebFilterChain::new)
56-
.flatMap((securedChain) -> securedChain.filter(exchange));
73+
.flatMap((securedChain) -> securedChain.filter(firewalledExchange));
74+
}
75+
76+
/**
77+
* Protects the application using the provided
78+
* {@link StrictServerWebExchangeFirewall}.
79+
* @param firewall the {@link StrictServerWebExchangeFirewall} to use. Cannot be null.
80+
* @since 5.7.13
81+
*/
82+
public void setFirewall(ServerWebExchangeFirewall firewall) {
83+
Assert.notNull(firewall, "firewall cannot be null");
84+
this.firewall = firewall;
85+
}
86+
87+
/**
88+
* Handles {@link ServerExchangeRejectedException} when the
89+
* {@link ServerWebExchangeFirewall} rejects the provided {@link ServerWebExchange}.
90+
* @param exchangeRejectedHandler the {@link ServerExchangeRejectedHandler} to use.
91+
* @since 5.7.13
92+
*/
93+
public void setExchangeRejectedHandler(ServerExchangeRejectedHandler exchangeRejectedHandler) {
94+
Assert.notNull(exchangeRejectedHandler, "exchangeRejectedHandler cannot be null");
95+
this.exchangeRejectedHandler = exchangeRejectedHandler;
5796
}
5897

5998
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.firewall;
18+
19+
import org.apache.commons.logging.Log;
20+
import org.apache.commons.logging.LogFactory;
21+
import reactor.core.publisher.Mono;
22+
23+
import org.springframework.core.log.LogMessage;
24+
import org.springframework.http.HttpStatus;
25+
import org.springframework.web.server.ServerWebExchange;
26+
27+
/**
28+
* A simple implementation of {@link ServerExchangeRejectedHandler} that sends an error
29+
* with configurable status code.
30+
*
31+
* @author Rob Winch
32+
* @since 5.7.13
33+
*/
34+
public class HttpStatusExchangeRejectedHandler implements ServerExchangeRejectedHandler {
35+
36+
private static final Log logger = LogFactory.getLog(HttpStatusExchangeRejectedHandler.class);
37+
38+
private final HttpStatus status;
39+
40+
/**
41+
* Constructs an instance which uses {@code 400} as response code.
42+
*/
43+
public HttpStatusExchangeRejectedHandler() {
44+
this(HttpStatus.BAD_REQUEST);
45+
}
46+
47+
/**
48+
* Constructs an instance which uses a configurable http code as response.
49+
* @param status http status code to use
50+
*/
51+
public HttpStatusExchangeRejectedHandler(HttpStatus status) {
52+
this.status = status;
53+
}
54+
55+
@Override
56+
public Mono<Void> handle(ServerWebExchange exchange,
57+
ServerExchangeRejectedException serverExchangeRejectedException) {
58+
return Mono.fromRunnable(() -> {
59+
logger.debug(
60+
LogMessage.format("Rejecting request due to: %s", serverExchangeRejectedException.getMessage()),
61+
serverExchangeRejectedException);
62+
exchange.getResponse().setStatusCode(this.status);
63+
});
64+
}
65+
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.firewall;
18+
19+
/**
20+
* Thrown when a {@link org.springframework.web.server.ServerWebExchange} is rejected.
21+
*
22+
* @author Rob Winch
23+
* @since 5.7.13
24+
*/
25+
public class ServerExchangeRejectedException extends RuntimeException {
26+
27+
public ServerExchangeRejectedException(String message) {
28+
super(message);
29+
}
30+
31+
}

0 commit comments

Comments
 (0)