From c2a83d2df5238a51d8141f39a3a5afe36dadb63c Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Wed, 5 Jul 2023 16:50:42 +0100 Subject: [PATCH] Support CSRF Request Header --- .../asciidoc/security-csrf-prevention.adoc | 42 +++++++++- .../reactive/runtime/CsrfReactiveConfig.java | 15 +++- .../CsrfRequestResponseReactiveFilter.java | 62 +++++++++------ .../runtime/CsrfTokenParameterProvider.java | 18 +++++ integration-tests/csrf-reactive/pom.xml | 17 ++++ .../java/io/quarkus/it/csrf/TestResource.java | 18 +++++ .../src/main/resources/application.properties | 8 +- .../resources/templates/csrfTokenHeader.html | 13 +++ .../io/quarkus/it/csrf/CsrfReactiveTest.java | 79 +++++++++++++++++++ 9 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 integration-tests/csrf-reactive/src/main/resources/templates/csrfTokenHeader.html diff --git a/docs/src/main/asciidoc/security-csrf-prevention.adoc b/docs/src/main/asciidoc/security-csrf-prevention.adoc index ca6e6f3365099..76e22280dcf90 100644 --- a/docs/src/main/asciidoc/security-csrf-prevention.adoc +++ b/docs/src/main/asciidoc/security-csrf-prevention.adoc @@ -9,10 +9,10 @@ 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 implements a https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie[Double Submit Cookie technique]. This techninque requires that the CSRF token is never directly exposed to scripts executed on the client-side. In this extension, the CSRF token is: +Quarkus Security provides a CSRF prevention feature which implements https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie[Double Submit Cookie] and [CSRF Request Header] techniques. -* sent as `HTTPOnly` cookie to the client, and -* directly embedded in a hidden form input of server-side rendered forms, which are transmitted to and used by the client. +`Double Submit Cookie` technique requires that the CSRF token sent as `HTTPOnly`, optionally signed, cookie to the client, and +directly embedded in a hidden form input of server-side rendered HTML forms, or submitted as a request header value. The extension 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]. @@ -137,6 +137,40 @@ You can get `HMAC` signatures created for the generated CSRF tokens and have the quarkus.csrf-reactive.token-signature-key=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow ---- +== CSRF Request Header + +If HTML `form` tags are not used and you need to pass CSRF token as a header, then inject the header name and token, for example, into HTMX: + +[source,html] +---- + <1> + +---- +<1> This expression is used to inject a CSRF token header and token. This token will be verified by the CSRF filter against a CSRF cookie. + +Default header name is `X-CSRF-TOKEN`, you can customize it with `quarkus.csrf-reactive.token-header-name`, for example: + +[source,properties] +---- +quarkus.csrf-reactive.token-header-name=CUSTOM-X-CSRF-TOKEN +---- + +If you need to access the CSRF cookie from JavaScript in order to pass its value as a header, use `{inject:csrf.cookieName}` and `{inject:csrf.headerName}` to inject the cookie name which has to be read as a CSRF header value and allow accessing this cookie: + +[source,properties] +---- +quarkus.csrf-reactive.cookie-http-only=false +---- + +== Cross-origin resource sharing + +[NOTE] +==== +If you would like to enforce CSRF prevention in a Cross-origin environment, please avoid supporting all Origins. + +Restrict supported Origins to trusted Origins only, see xref:http-reference.adoc#cors-filter[Cross-origin resource sharing] for more information. +==== + == Restrict CSRF token verification Your Jakarta REST endpoint may accept not only HTTP POST requests with `application/x-www-form-urlencoded` or `multipart/form-data` payloads but also payloads with other media types, either on the same or different URL paths, and therefore you would like to avoid verifying the CSRF token in such cases, for example: @@ -221,6 +255,7 @@ As you can see a CSRF token verification will be required at the `/service/user` quarkus.csrf-reactive.create-token-path=/service/user # If `/service/user` path accepts not only `application/x-www-form-urlencoded` payloads but also other ones such as JSON then allow them +# Setting this property is not necessary when the token is submitted as a header value quarkus.csrf-reactive.require-form-url-encoded=false ---- @@ -291,4 +326,5 @@ include::{generated-dir}/config/quarkus-csrf-reactive.adoc[leveloffset=+1, opts= * https://owasp.org/www-community/attacks/csrf[OWASP Cross-Site Request Forgery] * xref:resteasy-reactive.adoc[RESTEasy Reactive] * xref:qute-reference.adoc[Qute Reference] +* xref:http-reference.adoc#cors-filter[Cross-origin resource sharing] * xref:security-overview.adoc[Quarkus Security overview] diff --git a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java index e4bac6a7c5d1b..22aa399e41d07 100644 --- a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java @@ -19,6 +19,12 @@ public class CsrfReactiveConfig { @ConfigItem(defaultValue = "csrf-token") public String formFieldName; + /** + * Token header which will provide a CSRF token. + */ + @ConfigItem + public String tokenHeaderName; + /** * CSRF cookie name. */ @@ -51,6 +57,11 @@ public class CsrfReactiveConfig { @ConfigItem(defaultValue = "false") public boolean cookieForceSecure; + /** + * Set the HttpOnly attribute to prevent access to the cookie via JavaScript. + */ + @ConfigItem(defaultValue = "true") + public boolean cookieHttpOnly = true; /** * 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. @@ -73,7 +84,6 @@ public class CsrfReactiveConfig { /** * Verify CSRF token in the CSRF filter. - * If this property is enabled then the input stream will be read and cached by the CSRF filter to verify the token. * * If you prefer then you can disable this property and compare * CSRF form and cookie parameters in the application code using JAX-RS jakarta.ws.rs.FormParam which refers to the @@ -92,7 +102,8 @@ public class CsrfReactiveConfig { * 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. + * This property is only effective if {@link #verifyToken} property is enabled and {@link #tokenHeaderName} is not + * configured. */ @ConfigItem(defaultValue = "true") public boolean requireFormUrlEncoded; diff --git a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java index 8fcba71e714b4..91e69d36f20aa 100644 --- a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java @@ -97,6 +97,15 @@ public void filter(ResteasyReactiveContainerRequestContext requestContext, Routi } else if (config.verifyToken) { // unsafe HTTP method, token is required + // Check the header first + String csrfTokenInHeader = requestContext.getHeaderString(config.tokenHeaderName); + if (csrfTokenInHeader != null) { + LOG.debugf("CSRF token found in the token header"); + verifyCsrfToken(requestContext, routing, config, cookieToken, csrfTokenInHeader); + return; + } + + // Check the form field if (!isMatchingMediaType(requestContext.getMediaType(), MediaType.APPLICATION_FORM_URLENCODED_TYPE) && !isMatchingMediaType(requestContext.getMediaType(), MediaType.MULTIPART_FORM_DATA_TYPE)) { if (config.requireFormUrlEncoded) { @@ -116,35 +125,42 @@ public void filter(ResteasyReactiveContainerRequestContext requestContext, Routi return; } - if (cookieToken == null) { - LOG.debug("CSRF cookie is not found"); - requestContext.abortWith(badClientRequest()); - return; - } - ResteasyReactiveRequestContext rrContext = (ResteasyReactiveRequestContext) requestContext .getServerRequestContext(); String csrfToken = (String) rrContext.getFormParameter(config.formFieldName, true, false); - if (csrfToken == null) { - LOG.debug("CSRF token is not found"); + LOG.debugf("CSRF token found in the form parameter"); + verifyCsrfToken(requestContext, routing, config, cookieToken, csrfToken); + return; + + } else if (cookieToken == null) { + LOG.debug("CSRF token is not found"); + requestContext.abortWith(badClientRequest()); + } + } + + private void verifyCsrfToken(ResteasyReactiveContainerRequestContext requestContext, RoutingContext routing, + CsrfReactiveConfig config, String cookieToken, String csrfToken) { + if (cookieToken == null) { + LOG.debug("CSRF cookie is not found"); + requestContext.abortWith(badClientRequest()); + return; + } + if (csrfToken == null) { + LOG.debug("CSRF token is not found"); + requestContext.abortWith(badClientRequest()); + return; + } else { + String expectedCookieTokenValue = config.tokenSignatureKey.isPresent() + ? CsrfTokenUtils.signCsrfToken(csrfToken, config.tokenSignatureKey.get()) + : csrfToken; + if (!cookieToken.equals(expectedCookieTokenValue)) { + LOG.debug("CSRF token value is wrong"); requestContext.abortWith(badClientRequest()); return; } else { - String expectedCookieTokenValue = config.tokenSignatureKey.isPresent() - ? CsrfTokenUtils.signCsrfToken(csrfToken, config.tokenSignatureKey.get()) - : csrfToken; - if (!cookieToken.equals(expectedCookieTokenValue)) { - LOG.debug("CSRF token value is wrong"); - requestContext.abortWith(badClientRequest()); - return; - } else { - routing.put(CSRF_TOKEN_VERIFIED, true); - return; - } + routing.put(CSRF_TOKEN_VERIFIED, true); + return; } - } else if (cookieToken == null) { - LOG.debug("CSRF token is not found"); - requestContext.abortWith(badClientRequest()); } } @@ -223,7 +239,7 @@ private boolean isCsrfTokenRequired(RoutingContext routing, CsrfReactiveConfig c private void createCookie(String csrfToken, RoutingContext routing, CsrfReactiveConfig config) { ServerCookie cookie = new CookieImpl(config.cookieName, csrfToken); - cookie.setHttpOnly(true); + cookie.setHttpOnly(config.cookieHttpOnly); cookie.setSecure(config.cookieForceSecure || routing.request().isSSL()); cookie.setMaxAge(config.cookieMaxAge.toSeconds()); cookie.setPath(config.cookiePath); diff --git a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java index 85294cedf4c1e..1cec0a0bc2288 100644 --- a/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java @@ -21,9 +21,13 @@ public class CsrfTokenParameterProvider { RoutingContext context; private final String csrfFormFieldName; + private final String csrfCookieName; + private final String csrfHeaderName; public CsrfTokenParameterProvider(CsrfReactiveConfig config) { this.csrfFormFieldName = config.formFieldName; + this.csrfCookieName = config.cookieName; + this.csrfHeaderName = config.tokenHeaderName; } /** @@ -48,4 +52,18 @@ public String getToken() { public String getParameterName() { return csrfFormFieldName; } + + /** + * Gets the CSRF cookie name. + */ + public String getCookieName() { + return csrfCookieName; + } + + /** + * Gets the CSRF header name. + */ + public String getHeaderName() { + return csrfHeaderName; + } } diff --git a/integration-tests/csrf-reactive/pom.xml b/integration-tests/csrf-reactive/pom.xml index 9c862c7798fa6..b25da69b1cc4e 100644 --- a/integration-tests/csrf-reactive/pom.xml +++ b/integration-tests/csrf-reactive/pom.xml @@ -19,6 +19,10 @@ io.quarkus quarkus-csrf-reactive + + io.quarkus + quarkus-resteasy-reactive-jackson + io.quarkus quarkus-elytron-security-properties-file @@ -51,6 +55,19 @@ + + io.quarkus + quarkus-resteasy-reactive-jackson-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-elytron-security-properties-file-deployment diff --git a/integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java b/integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java index e4e28dc289a8f..e46ba7d06a5f5 100644 --- a/integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java +++ b/integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java @@ -28,6 +28,9 @@ public class TestResource { @Inject Template csrfTokenForm; + @Inject + Template csrfTokenHeader; + @Inject Template csrfTokenWithFormRead; @@ -52,6 +55,13 @@ public TemplateInstance getCsrfTokenWithFormRead() { return csrfTokenWithFormRead.instance(); } + @GET + @Path("/csrfTokenWithHeader") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance getCsrfTokenWithHeader() { + return csrfTokenHeader.instance(); + } + @POST @Path("/csrfTokenForm") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -68,6 +78,14 @@ public String postCsrfTokenWithFormRead() { return "verified:" + routingContext.get("csrf_token_verified", false); } + @POST + @Path("/csrfTokenWithHeader") + @Consumes({ MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON }) + @Produces(MediaType.TEXT_PLAIN) + public String postCsrfTokenWithHeader() { + return "verified:" + routingContext.get("csrf_token_verified", false); + } + @GET @Path("/csrfTokenMultipart") @Produces(MediaType.TEXT_HTML) diff --git a/integration-tests/csrf-reactive/src/main/resources/application.properties b/integration-tests/csrf-reactive/src/main/resources/application.properties index bf34b8f60188c..cbb9e16173c4d 100644 --- a/integration-tests/csrf-reactive/src/main/resources/application.properties +++ b/integration-tests/csrf-reactive/src/main/resources/application.properties @@ -1,9 +1,13 @@ quarkus.csrf-reactive.cookie-name=csrftoken -quarkus.csrf-reactive.create-token-path=/service/csrfTokenForm,/service/csrfTokenWithFormRead,/service/csrfTokenMultipart +quarkus.csrf-reactive.create-token-path=/service/csrfTokenForm,/service/csrfTokenWithFormRead,/service/csrfTokenMultipart,/service/csrfTokenWithHeader quarkus.csrf-reactive.token-signature-key=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +quarkus.csrf-reactive.token-header-name=X-CSRF-TOKEN quarkus.http.auth.basic=true quarkus.security.users.embedded.enabled=true quarkus.security.users.embedded.plain-text=true quarkus.security.users.embedded.users.alice=alice -quarkus.security.users.embedded.roles.alice=admin \ No newline at end of file +quarkus.security.users.embedded.roles.alice=admin + +quarkus.log.category."io.quarkus.csrf.reactive.runtime.CsrfRequestResponseReactiveFilter".min-level=TRACE +quarkus.log.category."io.quarkus.csrf.reactive.runtime.CsrfRequestResponseReactiveFilter".level=TRACE \ No newline at end of file diff --git a/integration-tests/csrf-reactive/src/main/resources/templates/csrfTokenHeader.html b/integration-tests/csrf-reactive/src/main/resources/templates/csrfTokenHeader.html new file mode 100644 index 0000000000000..f41bc7045ba68 --- /dev/null +++ b/integration-tests/csrf-reactive/src/main/resources/templates/csrfTokenHeader.html @@ -0,0 +1,13 @@ + + + + +CSRF Token Header Test + + +

CSRF Test

+ + + + + diff --git a/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java b/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java index 69f4e06ab7573..8bc161b65ee50 100644 --- a/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java +++ b/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java @@ -6,19 +6,23 @@ import static org.junit.jupiter.api.Assertions.fail; import java.util.Base64; +import java.util.List; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; import com.gargoylesoftware.htmlunit.TextPage; import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.DomElement; import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.util.Cookie; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; +import io.restassured.http.Header; @QuarkusTest public class CsrfReactiveTest { @@ -190,6 +194,81 @@ public void testWrongCsrfTokenFormValue() throws Exception { } } + @Test + public void testCsrfTokenHeaderValue() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenWithHeader"); + assertEquals("CSRF Token Header Test", htmlPage.getTitleText()); + List inputs = htmlPage.getElementsByIdAndOrName("X-CSRF-TOKEN"); + String csrfToken = inputs.get(0).asNormalizedText(); + + Cookie csrfCookie = webClient.getCookieManager().getCookie("csrftoken"); + assertNotNull(csrfCookie); + + RestAssured.given() + .header("Authorization", basicAuth("alice", "alice")) + .header(new Header("X-CSRF-TOKEN", csrfToken)) + .cookie(csrfCookie.getName(), csrfCookie.getValue()) + .urlEncodingEnabled(true) + .param("csrf-header", "X-CSRF-TOKEN") + .post("/service/csrfTokenWithHeader") + .then() + .body(Matchers.equalTo("verified:true")); + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testCsrfTokenHeaderValueJson() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenWithHeader"); + assertEquals("CSRF Token Header Test", htmlPage.getTitleText()); + List inputs = htmlPage.getElementsByIdAndOrName("X-CSRF-TOKEN"); + String csrfToken = inputs.get(0).asNormalizedText(); + + Cookie csrfCookie = webClient.getCookieManager().getCookie("csrftoken"); + assertNotNull(csrfCookie); + + RestAssured.given() + .header("Authorization", basicAuth("alice", "alice")) + .header(new Header("X-CSRF-TOKEN", csrfToken)) + .cookie(csrfCookie.getName(), csrfCookie.getValue()) + .header(new Header("Content-Type", "application/json")) + .body("{}") + .post("/service/csrfTokenWithHeader") + .then() + .body(Matchers.equalTo("verified:true")); + + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testWrongCsrfTokenHeaderValue() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenWithHeader"); + assertEquals("CSRF Token Header Test", htmlPage.getTitleText()); + + Cookie csrfCookie = webClient.getCookieManager().getCookie("csrftoken"); + assertNotNull(csrfCookie); + + RestAssured.given() + .header("Authorization", basicAuth("alice", "alice")) + // CSRF cookie is signed, so passing it as a header value will fail + .header(new Header("X-CSRF-TOKEN", csrfCookie.getValue())) + .cookie(csrfCookie.getName(), csrfCookie.getValue()) + .urlEncodingEnabled(true) + .param("csrf-header", "X-CSRF-TOKEN") + .post("/service/csrfTokenWithHeader") + .then() + .statusCode(400); + webClient.getCookieManager().clearCookies(); + } + } + @Test public void testWrongCsrfTokenWithFormRead() throws Exception { try (final WebClient webClient = createWebClient()) {