Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update CSRF filter to support multipart/form-data payloads #28383

Merged
merged 1 commit into from Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/src/main/asciidoc/security-csrf-prevention.adoc
Expand Up @@ -9,7 +9,7 @@ include::./attributes.adoc[]

https://owasp.org/www-community/attacks/csrf[Cross-Site Request Forgery (CSRF)] is an attack that forces an end user to execute unwanted actions on a web application in which they are currently authenticated.

Quarkus Security provides a CSRF prevention feature which consists of a xref:resteasy-reactive.adoc[RESTEasy Reactive] server filter which creates and verifies CSRF tokens and an HTML form parameter provider which supports the xref:qute-reference.adoc#injecting-beans-directly-in-templates[injection of CSRF tokens in Qute templates].
Quarkus Security provides a CSRF prevention feature which consists of a xref:resteasy-reactive.adoc[RESTEasy Reactive] server filter which creates and verifies CSRF tokens in 'application/x-www-form-urlencoded' and 'multipart/form-data' forms and a Qute HTML form parameter provider which supports the xref:qute-reference.adoc#injecting-beans-directly-in-templates[injection of CSRF tokens in Qute templates].

== Creating the Project

Expand Down Expand Up @@ -123,7 +123,7 @@ quarkus.csrf-reactive.form-field-name=csrftoken
quarkus.csrf-reactive.cookie-name=csrftoken
----

Note that the CSRF filter has to read the input stream in order to verify the token and then re-create the stream for the application code to read it as well. The filter performs this work on an event loop thread so for small form payloads, such as the one shown in the example above, it will have negligible performance side-effects. However if you deal with large form payloads then it is recommended to compare the CSRF form field and cookie values in the application code:
Note that the CSRF filter has to read and cache the input stream in order to verify the token. However if you prefer you can compare the CSRF form field and cookie values in the application code:

[source,java]
----
Expand Down
Expand Up @@ -2,6 +2,7 @@

import java.time.Duration;
import java.util.Optional;
import java.util.Set;

import io.quarkus.runtime.annotations.ConfigItem;
import io.quarkus.runtime.annotations.ConfigPhase;
Expand Down Expand Up @@ -51,11 +52,12 @@ public class CsrfReactiveConfig {
public boolean cookieForceSecure;

/**
* Create CSRF token only if the HTTP GET relative request path is the same as the one configured with this property.
* Create CSRF token only if the HTTP GET relative request path matches one of the paths configured with this property.
* Use a comma to separate multiple path values.
*
*/
@ConfigItem
public Optional<String> createTokenPath;
public Optional<Set<String>> createTokenPath;

/**
* The random CSRF token size in bytes.
Expand All @@ -65,24 +67,24 @@ public class CsrfReactiveConfig {

/**
* Verify CSRF token in the CSRF filter.
* If this property is enabled then the input stream will be read by the CSRF filter to verify the token
* and recreated for the application code to read the data correctly.
* If this property is enabled then the input stream will be read and cached by the CSRF filter to verify the token.
*
* Therefore, it is recommended to disable this property when dealing with the large form payloads and instead compare
* CSRF form and cookie parameters in the application code using JAX-RS {@linkplain FormParam} which refers to the
* If you prefer then you can disable this property and compare
* CSRF form and cookie parameters in the application code using JAX-RS javax.ws.rs.FormParam which refers to the
* {@link #formFieldName}
* form property and {@linkplain CookieParam} which refers to the {@link CsrfReactiveConfig#cookieName} cookie.
* form property and javax.ws.rs.CookieParam which refers to the {@link CsrfReactiveConfig#cookieName} cookie.
*
* Note that even if the CSRF token verification in the CSRF filter is disabled, the filter will still perform checks to
* ensure the token
* is available, has the correct {@linkplain #tokenSize} in bytes and that the Content-Type HTTP header is
* 'application/x-www-form-urlencoded'.
* either 'application/x-www-form-urlencoded' or 'multipart/form-data'.
*/
@ConfigItem(defaultValue = "true")
public boolean verifyToken;

/**
* Require that only 'application/x-www-form-urlencoded' body is accepted for the token verification to proceed.
* Require that only 'application/x-www-form-urlencoded' or 'multipart/form-data' body is accepted for the token
* verification to proceed.
* Disable this property for the CSRF filter to avoid verifying the token for POST requests with other content types.
* This property is only effective if {@link #verifyToken} property is enabled.
*/
Expand Down
@@ -1,11 +1,7 @@
package io.quarkus.csrf.reactive.runtime;

import java.io.ByteArrayInputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;

Expand All @@ -29,7 +25,6 @@
import io.vertx.core.http.impl.CookieImpl;
import io.vertx.core.http.impl.ServerCookie;
import io.vertx.ext.web.RoutingContext;
import io.vertx.mutiny.core.buffer.Buffer;

public class CsrfRequestResponseReactiveFilter {
private static final Logger LOG = Logger.getLogger(CsrfRequestResponseReactiveFilter.class);
Expand Down Expand Up @@ -100,9 +95,8 @@ public Uni<Response> filter(ContainerRequestContext requestContext, RoutingConte
} else if (config.verifyToken) {
// unsafe HTTP method, token is required

if (!requestContext.getMediaType().getType().equals(MediaType.APPLICATION_FORM_URLENCODED_TYPE.getType())
|| !requestContext.getMediaType().getSubtype()
.equals(MediaType.APPLICATION_FORM_URLENCODED_TYPE.getSubtype())) {
if (!isMatchingMediaType(requestContext.getMediaType(), MediaType.APPLICATION_FORM_URLENCODED_TYPE)
&& !isMatchingMediaType(requestContext.getMediaType(), MediaType.MULTIPART_FORM_DATA_TYPE)) {
if (config.requireFormUrlEncoded) {
LOG.debugf("Request has the wrong media type: %s", requestContext.getMediaType().toString());
return Uni.createFrom().item(badClientRequest());
Expand Down Expand Up @@ -137,7 +131,6 @@ public Uni<Response> apply(MultiMap form) {
return Uni.createFrom().item(badClientRequest());
} else {
routing.put(CSRF_TOKEN_VERIFIED, true);
requestContext.setEntityStream(new ByteArrayInputStream(encodeForm(form).getBytes()));
}
return Uni.createFrom().nullItem();
}
Expand All @@ -150,6 +143,11 @@ public Uni<Response> apply(MultiMap form) {
return null;
}

private static boolean isMatchingMediaType(MediaType contentType, MediaType expectedType) {
return contentType.getType().equals(expectedType.getType())
&& contentType.getSubtype().equals(expectedType.getSubtype());
}

private static Response badClientRequest() {
return Response.status(400).build();
}
Expand Down Expand Up @@ -199,7 +197,7 @@ private String getCookieToken(RoutingContext routing, CsrfReactiveConfig config)
}

private boolean isCsrfTokenRequired(RoutingContext routing, CsrfReactiveConfig config) {
return config.createTokenPath.isPresent() ? config.createTokenPath.get().equals(routing.request().path()) : true;
return config.createTokenPath.isPresent() ? config.createTokenPath.get().contains(routing.request().path()) : true;
}

private void createCookie(String csrfToken, RoutingContext routing, CsrfReactiveConfig config) {
Expand Down Expand Up @@ -241,24 +239,4 @@ public void handle(Void event) {
});
}

private static Buffer encodeForm(MultiMap form) {
Buffer buffer = Buffer.buffer();
for (Map.Entry<String, String> entry : form) {
if (buffer.length() != 0) {
buffer.appendByte((byte) '&');
}
buffer.appendString(entry.getKey());
buffer.appendByte((byte) '=');
buffer.appendString(urlEncode(entry.getValue()));
}
return buffer;
}

private static String urlEncode(String value) {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
}
Expand Up @@ -2,11 +2,13 @@

import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.CookieParam;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.MediaType;

import io.quarkus.qute.Template;
Expand All @@ -17,7 +19,10 @@
public class TestResource {

@Inject
Template csrfToken;
Template csrfTokenForm;

@Inject
Template csrfTokenMultipart;

@Inject
RoutingContext routingContext;
Expand All @@ -26,7 +31,7 @@ public class TestResource {
@Path("/csrfTokenForm")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getCsrfTokenForm() {
return csrfToken.instance();
return csrfTokenForm.instance();
}

@POST
Expand All @@ -37,6 +42,23 @@ public String postCsrfTokenForm(@FormParam("name") String name) {
return name + ":" + routingContext.get("csrf_token_verified", false);
}

@GET
@Path("/csrfTokenMultipart")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getCsrfTokenMultipart() {
return csrfTokenMultipart.instance();
}

@POST
@Path("/csrfTokenMultipart")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.TEXT_PLAIN)
public String postCsrfTokenMultipart(@FormParam("name") String name,
@FormParam("csrf-token") String csrfTokenParam, @CookieParam("csrftoken") Cookie csrfTokenCookie) {
return name + ":" + routingContext.get("csrf_token_verified", false) + ":"
+ csrfTokenCookie.getValue().equals(csrfTokenParam);
}

@GET
@Path("/hello")
@Produces(MediaType.TEXT_PLAIN)
Expand Down
@@ -1,2 +1,3 @@
quarkus.csrf-reactive.cookie-name=csrftoken
quarkus.csrf-reactive.create-token-path=/service/csrfTokenForm
quarkus.csrf-reactive.create-token-path=/service/csrfTokenForm,/service/csrfTokenMultipart

Expand Up @@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<title>CSRF Token Test</title>
<title>CSRF Token Form Test</title>
</head>
<body>
<h1>CSRF Test</h1>
Expand Down
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CSRF Token Multipart Test</title>
</head>
<body>
<h1>CSRF Test</h1>

<form action="/service/csrfTokenMultipart" enctype="multipart/form-data" method="post">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" />

<p>Your Name: <input type="text" name="name" /></p>
<p><input type="submit" name="submit"/></p>
</form>
</body>
</html>
Expand Up @@ -22,12 +22,12 @@
public class CsrfReactiveTest {

@Test
public void testCsrfToken() throws Exception {
public void testCsrfTokenInForm() throws Exception {
try (final WebClient webClient = createWebClient()) {

HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm");

assertEquals("CSRF Token Test", htmlPage.getTitleText());
assertEquals("CSRF Token Form Test", htmlPage.getTitleText());

HtmlForm loginForm = htmlPage.getForms().get(0);

Expand All @@ -52,7 +52,7 @@ public void testCsrfTokenInFormButNoCookie() throws Exception {

HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm");

assertEquals("CSRF Token Test", htmlPage.getTitleText());
assertEquals("CSRF Token Form Test", htmlPage.getTitleText());

HtmlForm loginForm = htmlPage.getForms().get(0);

Expand All @@ -73,13 +73,38 @@ public void testCsrfTokenInFormButNoCookie() throws Exception {
}
}

@Test
public void testCsrfTokenInMultipart() throws Exception {
try (final WebClient webClient = createWebClient()) {

HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenMultipart");

assertEquals("CSRF Token Multipart Test", htmlPage.getTitleText());

HtmlForm loginForm = htmlPage.getForms().get(0);

loginForm.getInputByName("name").setValueAttribute("alice");

assertNotNull(webClient.getCookieManager().getCookie("csrftoken"));

TextPage textPage = loginForm.getInputByName("submit").click();

assertEquals("alice:true:true", textPage.getContent());

textPage = webClient.getPage("http://localhost:8081/service/hello");
assertEquals("hello", textPage.getContent());

webClient.getCookieManager().clearCookies();
}
}

@Test
public void testWrongCsrfTokenCookieValue() throws Exception {
try (final WebClient webClient = createWebClient()) {

HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm");

assertEquals("CSRF Token Test", htmlPage.getTitleText());
assertEquals("CSRF Token Form Test", htmlPage.getTitleText());

HtmlForm loginForm = htmlPage.getForms().get(0);

Expand Down Expand Up @@ -109,7 +134,7 @@ public void testWrongCsrfTokenFormValue() throws Exception {

HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm");

assertEquals("CSRF Token Test", htmlPage.getTitleText());
assertEquals("CSRF Token Form Test", htmlPage.getTitleText());

assertNotNull(webClient.getCookieManager().getCookie("csrftoken"));

Expand Down