From 618b8d56d712a29cc5ae6a9e5fc7dc5b794c1098 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Mon, 29 Aug 2022 17:57:45 +0100 Subject: [PATCH] Add CrossSite Request Forgery prevention filter --- bom/application/pom.xml | 10 + devtools/bom-descriptor-json/pom.xml | 13 + docs/pom.xml | 13 + .../asciidoc/security-csrf-prevention.adoc | 196 +++++++++++++ docs/src/main/asciidoc/security.adoc | 4 + extensions/csrf-reactive/deployment/pom.xml | 59 ++++ .../CsrfReactiveAlwaysEnabledProcessor.java | 14 + .../csrf/reactive/CsrfReactiveBuildStep.java | 35 +++ .../reactive/CsrfReactiveBuildTimeConfig.java | 16 ++ extensions/csrf-reactive/pom.xml | 20 ++ extensions/csrf-reactive/runtime/pom.xml | 59 ++++ .../reactive/runtime/CsrfReactiveConfig.java | 91 ++++++ .../CsrfRequestResponseReactiveFilter.java | 264 ++++++++++++++++++ .../runtime/CsrfTokenParameterProvider.java | 51 ++++ .../resources/META-INF/quarkus-extension.yaml | 11 + extensions/pom.xml | 1 + integration-tests/csrf-reactive/pom.xml | 68 +++++ .../java/io/quarkus/it/csrf/TestResource.java | 46 +++ .../src/main/resources/application.properties | 2 + .../main/resources/templates/csrfToken.html | 17 ++ .../quarkus/it/csrf/CsrfReactiveITCase.java | 7 + .../io/quarkus/it/csrf/CsrfReactiveTest.java | 130 +++++++++ integration-tests/pom.xml | 1 + 23 files changed, 1128 insertions(+) create mode 100644 docs/src/main/asciidoc/security-csrf-prevention.adoc create mode 100644 extensions/csrf-reactive/deployment/pom.xml create mode 100644 extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveAlwaysEnabledProcessor.java create mode 100644 extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildStep.java create mode 100644 extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildTimeConfig.java create mode 100644 extensions/csrf-reactive/pom.xml create mode 100644 extensions/csrf-reactive/runtime/pom.xml create mode 100644 extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java create mode 100644 extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java create mode 100644 extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java create mode 100644 extensions/csrf-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 integration-tests/csrf-reactive/pom.xml create mode 100644 integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java create mode 100644 integration-tests/csrf-reactive/src/main/resources/application.properties create mode 100644 integration-tests/csrf-reactive/src/main/resources/templates/csrfToken.html create mode 100644 integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveITCase.java create mode 100644 integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 734ba16764dd3..f9b91ef597046 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -775,6 +775,16 @@ quarkus-elytron-security-oauth2-deployment ${project.version} + + io.quarkus + quarkus-csrf-reactive + ${project.version} + + + io.quarkus + quarkus-csrf-reactive-deployment + ${project.version} + io.quarkus quarkus-oidc diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 7344ae2ac64f7..97a02688c91e2 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -395,6 +395,19 @@ + + io.quarkus + quarkus-csrf-reactive + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-datasource diff --git a/docs/pom.xml b/docs/pom.xml index 1e8885c78aa9f..2305f5ef103e9 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -381,6 +381,19 @@ + + io.quarkus + quarkus-csrf-reactive-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-datasource-deployment diff --git a/docs/src/main/asciidoc/security-csrf-prevention.adoc b/docs/src/main/asciidoc/security-csrf-prevention.adoc new file mode 100644 index 0000000000000..376c5934a5469 --- /dev/null +++ b/docs/src/main/asciidoc/security-csrf-prevention.adoc @@ -0,0 +1,196 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// += Cross-Site Request Forgery Prevention + +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]. + +== Creating the Project + +First, we need a new project. +Create a new project with the following command: + +:create-app-artifact-id: security-csrf-prevention +:create-app-extensions: csrf-reactive +include::{includes}/devtools/create-app.adoc[] + +This command generates a project which imports the `csrf-reactive` extension. + +If you already have your Quarkus project configured, you can add the `csrf-reactive` extension +to your project by running the following command in your project base directory: + +:add-extension-extensions: csrf-reactive +include::{includes}/devtools/extension-add.adoc[] + +This will add the following to your build file: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-csrf-reactive + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +implementation("io.quarkus:quarkus-csrf-reactive") +---- + +Next lets add a Qute template producing an HTML form: + +[source,html] +---- + + + + +User Name Input + + +

User Name Input

+ +
+ <1> + +

Your Name:

+

+
+ + +---- + +<1> This expression is used to inject a CSRF token into a hidden form field. This token will be verified by the CSRF filter against a CSRF cookie. + +You can name the file containing this template as `csrfToken.html` and put it in a `src/main/resources/templates` folder. + +Now let's create a resource class which returns an HTML form and handles form POST requests: + +[source,java] +---- +package io.quarkus.it.csrf; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +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.MediaType; + +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; + +@Path("/service") +public class UserNameResource { + + @Inject + Template csrfToken; <1> + + @GET + @Path("/csrfTokenForm") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance getCsrfTokenForm() { + return csrfToken.instance(); <2> + } + + @POST + @Path("/csrfTokenForm") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + public String postCsrfTokenForm(@FormParam("name") String name) { + return userName; <3> + } +} +---- + +<1> Inject the `csrfToken.html` as a `Template`. +<2> Return HTML form with a hidden form field containing a CSRF token created by the CSRF filter. +<3> Handle the form POST request, this method can only be invoked only if the CSRF filter has successfully verified the token. + +The form POST request will fail with HTTP status `400` if the filter finds the hidden CSRF form field is missing, the CSRF cookie is missing, or if the CSRF form field and CSRF cookie values do not match. + +At this stage no additional configuration is needed - by default the CSRF form field and cookie name will be set to `csrf_token`, and the filter will verify the token. But lets change these names: + +[source,properties] +---- +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 peformance 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: + +[source,java] +---- +package io.quarkus.it.csrf; + +import javax.inject.Inject; +import javax.ws.rs.BadRequestException; +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.MediaType; + +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; + +@Path("/service") +public class UserNameResource { + + @Inject + Template csrfToken; + + @GET + @Path("/csrfTokenForm") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance getCsrfTokenForm() { + return csrfToken.instance(); + } + + @POST + @Path("/csrfTokenForm") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + public String postCsrfTokenForm(@CookieParam("csrf-token") csrfCookie, @FormParam("csrf-token") String formCsrfToken, @FormParam("name") String userName) { + if (!csrfCookie.getValue().equals(formCsrfToken)) { <1> + throw new BadRequestException(); + } + return userName; + } +} +---- + +<1> Compare the CSRF form field and cookie values and fail with HTTP status `400` if they don't match. + +Also disable the token verification in the filter: + +[source,properties] +---- +quarkus.csrf-reactive.verify-token=false +---- + + +[[csrf-reactive-configuration-reference]] +== Configuration Reference + +include::{generated-dir}/config/quarkus-csrf-reactive.adoc[leveloffset=+1, opts=optional] + +== References + +* 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:security.adoc[Quarkus Security] diff --git a/docs/src/main/asciidoc/security.adoc b/docs/src/main/asciidoc/security.adoc index 961099401d349..2987891b95f72 100644 --- a/docs/src/main/asciidoc/security.adoc +++ b/docs/src/main/asciidoc/security.adoc @@ -263,6 +263,10 @@ See the xref:http-reference.adoc#ssl[Supporting secure connections with SSL] gui If you plan to make your Quarkus application accessible to another application running on a different domain, you will need to configure CORS (Cross-Origin Resource Sharing). Please read the xref:http-reference.adoc#cors-filter[HTTP CORS documentation] for more information. +== Cross-Site Request Forgery Prevention + +Quarkus Security provides a RESTEasy Reactive filter which can help protect against a https://owasp.org/www-community/attacks/csrf[Cross-Site Request Forgery] attack. Please read the xref:csrf-prevention.adoc[Cross-Site Request Forgery Prevention] guide for more information. + == SameSite cookies Please see xref:http-reference.adoc#same-site-cookie[SameSite cookies] for information about adding a https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite[SameSite] cookie property to any of the cookies set by a Quarkus endpoint. diff --git a/extensions/csrf-reactive/deployment/pom.xml b/extensions/csrf-reactive/deployment/pom.xml new file mode 100644 index 0000000000000..a237a9890e57c --- /dev/null +++ b/extensions/csrf-reactive/deployment/pom.xml @@ -0,0 +1,59 @@ + + + + quarkus-csrf-reactive-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-csrf-reactive-deployment + Quarkus - Cross-Site Request Forgery Filter Reactive - Deployment + + + + io.quarkus + quarkus-csrf-reactive + + + io.quarkus + quarkus-resteasy-reactive-deployment + + + io.quarkus + quarkus-resteasy-reactive-qute-deployment + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveAlwaysEnabledProcessor.java b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveAlwaysEnabledProcessor.java new file mode 100644 index 0000000000000..90be8a6838818 --- /dev/null +++ b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveAlwaysEnabledProcessor.java @@ -0,0 +1,14 @@ +package io.quarkus.csrf.reactive; + +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.FeatureBuildItem; + +// Executed even if the extension is disabled, see https://github.com/quarkusio/quarkus/pull/26966/ +public class CsrfReactiveAlwaysEnabledProcessor { + + @BuildStep + FeatureBuildItem featureBuildItem() { + return new FeatureBuildItem("csrf-reactive"); + } + +} diff --git a/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildStep.java b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildStep.java new file mode 100644 index 0000000000000..886289a8c1810 --- /dev/null +++ b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildStep.java @@ -0,0 +1,35 @@ +package io.quarkus.csrf.reactive; + +import java.util.function.BooleanSupplier; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.csrf.reactive.runtime.CsrfRequestResponseReactiveFilter; +import io.quarkus.csrf.reactive.runtime.CsrfTokenParameterProvider; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; + +@BuildSteps(onlyIf = CsrfReactiveBuildStep.IsEnabled.class) +public class CsrfReactiveBuildStep { + + @BuildStep + void registerProvider(BuildProducer additionalBeans, + BuildProducer reflectiveClass, + BuildProducer additionalIndexedClassesBuildItem) { + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(CsrfRequestResponseReactiveFilter.class)); + reflectiveClass.produce(new ReflectiveClassBuildItem(true, true, CsrfRequestResponseReactiveFilter.class)); + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(CsrfTokenParameterProvider.class)); + additionalIndexedClassesBuildItem + .produce(new AdditionalIndexedClassesBuildItem(CsrfRequestResponseReactiveFilter.class.getName())); + } + + public static class IsEnabled implements BooleanSupplier { + CsrfReactiveBuildTimeConfig config; + + public boolean getAsBoolean() { + return config.enabled; + } + } +} diff --git a/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildTimeConfig.java b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildTimeConfig.java new file mode 100644 index 0000000000000..1e8cde2913225 --- /dev/null +++ b/extensions/csrf-reactive/deployment/src/main/java/io/quarkus/csrf/reactive/CsrfReactiveBuildTimeConfig.java @@ -0,0 +1,16 @@ +package io.quarkus.csrf.reactive; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Build time configuration for CSRF Reactive Filter. + */ +@ConfigRoot +public class CsrfReactiveBuildTimeConfig { + /** + * If filter is enabled. + */ + @ConfigItem(defaultValue = "true") + public boolean enabled; +} diff --git a/extensions/csrf-reactive/pom.xml b/extensions/csrf-reactive/pom.xml new file mode 100644 index 0000000000000..c58a36adef790 --- /dev/null +++ b/extensions/csrf-reactive/pom.xml @@ -0,0 +1,20 @@ + + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-csrf-reactive-parent + Quarkus - Cross-Site Request Forgery Prevention Filter Reactive + pom + + deployment + runtime + + diff --git a/extensions/csrf-reactive/runtime/pom.xml b/extensions/csrf-reactive/runtime/pom.xml new file mode 100644 index 0000000000000..c347f32368d35 --- /dev/null +++ b/extensions/csrf-reactive/runtime/pom.xml @@ -0,0 +1,59 @@ + + + + quarkus-csrf-reactive-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-csrf-reactive + Quarkus - Cross-Site Request Forgery Prevention Filter - Runtime + Use Reactive REST Server filters to prevent the risk of Cross-Site Request Forgery + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-vertx-http + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-resteasy-reactive-qute + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + 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 new file mode 100644 index 0000000000000..4b1e7ca2d3d4b --- /dev/null +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfReactiveConfig.java @@ -0,0 +1,91 @@ +package io.quarkus.csrf.reactive.runtime; + +import java.time.Duration; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Runtime configuration for CSRF Reactive Filter. + */ +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public class CsrfReactiveConfig { + /** + * Form field name which keeps a CSRF token. + */ + @ConfigItem(defaultValue = "csrf-token") + public String formFieldName; + + /** + * CSRF cookie name. + */ + @ConfigItem(defaultValue = "csrf-token") + public String cookieName; + + /** + * CSRF cookie max age. + */ + @ConfigItem(defaultValue = "10M") + public Duration cookieMaxAge; + + /** + * CSRF cookie path. + */ + @ConfigItem(defaultValue = "/") + public String cookiePath; + + /** + * CSRF cookie domain. + */ + @ConfigItem + public Optional cookieDomain; + + /** + * If enabled the CSRF cookie will have its 'secure' parameter set to 'true' + * when HTTP is used. It may be necessary when running behind an SSL terminating reverse proxy. + * The cookie will always be secure if HTTPS is used even if this property is set to false. + */ + @ConfigItem(defaultValue = "false") + public boolean cookieForceSecure; + + /** + * Create CSRF token only if the HTTP GET relative request path is the same as the one configured with this property. + * + */ + @ConfigItem + public Optional createTokenPath; + + /** + * The random CSRF token size in bytes. + */ + @ConfigItem(defaultValue = "16") + public int tokenSize; + + /** + * 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. + * + * 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 + * {@link #formFieldName} + * form property and {@linkplain 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'. + */ + @ConfigItem(defaultValue = "true") + public boolean verifyToken; + + /** + * Require that only 'application/x-www-form-urlencoded' 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. + */ + @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 new file mode 100644 index 0000000000000..9b2d32e280c64 --- /dev/null +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfRequestResponseReactiveFilter.java @@ -0,0 +1,264 @@ +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; + +import javax.enterprise.inject.Instance; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.ServerRequestFilter; +import org.jboss.resteasy.reactive.server.ServerResponseFilter; + +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.UniEmitter; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.http.Cookie; +import io.vertx.core.http.HttpServerRequest; +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); + + /** + * CSRF token key. + */ + private static final String CSRF_TOKEN_KEY = "csrf_token"; + + /** + * CSRF token verification status. + */ + private static final String CSRF_TOKEN_VERIFIED = "csrf_token_verified"; + + @Inject + Instance configInstance; + + public CsrfRequestResponseReactiveFilter() { + } + + /** + * If the request method is safe ({@code GET}, {@code HEAD} or {@code OPTIONS}): + *
    + *
  • Sets a {@link RoutingContext} key by the name {@value #CSRF_TOKEN_KEY} that contains a randomly generated Base64 + * encoded string, unless such a cookie was already sent in the incoming request.
  • + *
+ * If the request method is unsafe, requires the following: + *
    + *
  • The request contains a valid CSRF token cookie set in response to a previous request (see above).
  • + *
  • A request entity is present.
  • + *
  • The request {@code Content-Type} is {@value MediaType#APPLICATION_FORM_URLENCODED}.
  • + *
  • The request entity contains a form parameter with the name + * {@value #CSRF_TOKEN_KEY} and value that is equal to the one supplied in the cookie.
  • + *
+ */ + @ServerRequestFilter(preMatching = true) + public Uni filter(ContainerRequestContext requestContext, RoutingContext routing) { + final CsrfReactiveConfig config = this.configInstance.get(); + + String cookieToken = getCookieToken(routing, config); + if (cookieToken != null) { + routing.put(CSRF_TOKEN_KEY, cookieToken); + + try { + int suppliedTokenSize = Base64.getUrlDecoder().decode(cookieToken).length; + + if (suppliedTokenSize != config.tokenSize) { + LOG.debugf("Invalid CSRF token cookie size: expected %d, got %d", config.tokenSize, + suppliedTokenSize); + return Uni.createFrom().item(badClientRequest()); + } + } catch (IllegalArgumentException e) { + LOG.debugf("Invalid CSRF token cookie: %s", cookieToken); + return Uni.createFrom().item(badClientRequest()); + } + } + + if (requestMethodIsSafe(requestContext)) { + // safe HTTP method, tolerate the absence of a token + if (cookieToken == null && isCsrfTokenRequired(routing, config)) { + // Set the CSRF cookie with a randomly generated value + byte[] token = new byte[config.tokenSize]; + new SecureRandom().nextBytes(token); + routing.put(CSRF_TOKEN_KEY, Base64.getUrlEncoder().withoutPadding().encodeToString(token)); + } + } 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 (config.requireFormUrlEncoded) { + LOG.debugf("Request has the wrong media type: %s", requestContext.getMediaType().toString()); + return Uni.createFrom().item(badClientRequest()); + } else { + LOG.debugf("Request has the media type: %s, skipping the token verification", + requestContext.getMediaType().toString()); + return Uni.createFrom().nullItem(); + } + } + + if (!requestContext.hasEntity()) { + LOG.debug("Request has no entity"); + return Uni.createFrom().item(badClientRequest()); + } + + if (cookieToken == null) { + LOG.debug("CSRF cookie is not found"); + return Uni.createFrom().item(badClientRequest()); + } + + return getFormUrlEncodedData(routing.request()) + .flatMap(new Function>() { + @Override + public Uni apply(MultiMap form) { + + String csrfToken = form.get(config.formFieldName); + if (csrfToken == null) { + LOG.debug("CSRF token is not found"); + return Uni.createFrom().item(badClientRequest()); + } else if (!csrfToken.equals(cookieToken)) { + LOG.debug("CSRF token value is wrong"); + return Uni.createFrom().item(badClientRequest()); + } else { + routing.put(CSRF_TOKEN_VERIFIED, true); + requestContext.setEntityStream(new ByteArrayInputStream(encodeForm(form).getBytes())); + } + return Uni.createFrom().nullItem(); + } + }); + } else if (cookieToken == null) { + LOG.debug("CSRF token is not found"); + return Uni.createFrom().item(badClientRequest()); + } + + return null; + } + + private static Response badClientRequest() { + return Response.status(400).build(); + } + + /** + * If the requirements below are true, sets a cookie by the name {@value #CSRF_TOKEN_KEY} that contains a CSRF token. + *
    + *
  • The request method is {@code GET}.
  • + *
  • The request does not contain a valid CSRF token cookie.
  • + *
+ * + * @throws IllegalStateException if the {@link RoutingContext} does not have a value for the key {@value #CSRF_TOKEN_KEY} + * and a cookie needs to be set. + */ + @ServerResponseFilter + public void filter(ContainerRequestContext requestContext, + ContainerResponseContext responseContext, RoutingContext routing) { + final CsrfReactiveConfig config = configInstance.get(); + if (requestContext.getMethod().equals("GET") && isCsrfTokenRequired(routing, config) + && getCookieToken(routing, config) == null) { + String token = (String) routing.get(CSRF_TOKEN_KEY); + + if (token == null) { + throw new IllegalStateException( + "CSRF Filter should have set the property " + CSRF_TOKEN_KEY + ", but it is null"); + } + + createCookie(token, routing, config); + } + + } + + /** + * Gets the CSRF token from the CSRF cookie from the current {@code RoutingContext}. + * + * @return An Optional containing the token, or an empty Optional if the token cookie is not present or is invalid + */ + private String getCookieToken(RoutingContext routing, CsrfReactiveConfig config) { + Cookie cookie = routing.getCookie(config.cookieName); + + if (cookie == null) { + LOG.debug("CSRF token cookie is not set"); + return null; + } + + return cookie.getValue(); + } + + private boolean isCsrfTokenRequired(RoutingContext routing, CsrfReactiveConfig config) { + LOG.error("**************Request path: " + routing.request().path()); + LOG.error("**************Token path: " + config.createTokenPath.get()); + return config.createTokenPath.isPresent() ? config.createTokenPath.get().equals(routing.request().path()) : true; + } + + private void createCookie(String csrfToken, RoutingContext routing, CsrfReactiveConfig config) { + ServerCookie cookie = new CookieImpl(config.cookieName, csrfToken); + cookie.setHttpOnly(true); + cookie.setSecure(config.cookieForceSecure || routing.request().isSSL()); + cookie.setMaxAge(config.cookieMaxAge.toSeconds()); + cookie.setPath(config.cookiePath); + if (config.cookieDomain.isPresent()) { + cookie.setDomain(config.cookieDomain.get()); + } + routing.response().addCookie(cookie); + } + + private static boolean requestMethodIsSafe(ContainerRequestContext context) { + switch (context.getMethod()) { + case "GET": + case "HEAD": + case "OPTIONS": + return true; + default: + return false; + } + } + + private static Uni getFormUrlEncodedData(HttpServerRequest request) { + request.setExpectMultipart(true); + return Uni.createFrom().emitter(new Consumer>() { + @Override + public void accept(UniEmitter t) { + request.endHandler(new Handler() { + @Override + public void handle(Void event) { + t.complete(request.formAttributes()); + } + }); + request.resume(); + } + }); + } + + private static Buffer encodeForm(MultiMap form) { + Buffer buffer = Buffer.buffer(); + for (Map.Entry 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); + } + } +} 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 new file mode 100644 index 0000000000000..b84dd490da8de --- /dev/null +++ b/extensions/csrf-reactive/runtime/src/main/java/io/quarkus/csrf/reactive/runtime/CsrfTokenParameterProvider.java @@ -0,0 +1,51 @@ +package io.quarkus.csrf.reactive.runtime; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.inject.Named; + +import io.vertx.ext.web.RoutingContext; + +/** + * CSRF token form parameter provider which supports the injection of the CSRF token in Qute templates. + */ +@ApplicationScoped +@Named("csrf") +public class CsrfTokenParameterProvider { + /** + * CSRF token key. + */ + private static final String CSRF_TOKEN_KEY = "csrf_token"; + + @Inject + RoutingContext context; + + private final String csrfFormFieldName; + + public CsrfTokenParameterProvider(CsrfReactiveConfig config) { + this.csrfFormFieldName = config.formFieldName; + } + + /** + * Gets the CSRF token value. + * + * @throws IllegalStateException if the {@link RoutingContext} does not contain a CSRF token value. + */ + public String getToken() { + String token = (String) context.get(CSRF_TOKEN_KEY); + + if (token == null) { + throw new IllegalStateException( + "CSRFFilter should have set the attribute " + csrfFormFieldName + ", but it is null"); + } + + return token; + } + + /** + * Gets the name of the form parameter that is to contain the value returned by {@link #getToken()}. + */ + public String getParameterName() { + return csrfFormFieldName; + } +} \ No newline at end of file diff --git a/extensions/csrf-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/csrf-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..b734e26280eac --- /dev/null +++ b/extensions/csrf-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,11 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Cross-Site Request Forgery Prevention Filter Reactive" +metadata: + keywords: + - "csrf" + categories: + - "security" + status: "preview" + config: + - "quarkus.csrf-reactive." diff --git a/extensions/pom.xml b/extensions/pom.xml index 5d62daf4440f2..47d7509ab9f5c 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -140,6 +140,7 @@ keycloak-admin-client keycloak-admin-client-reactive credentials + csrf-reactive infinispan-client diff --git a/integration-tests/csrf-reactive/pom.xml b/integration-tests/csrf-reactive/pom.xml new file mode 100644 index 0000000000000..d97c3794e5e3e --- /dev/null +++ b/integration-tests/csrf-reactive/pom.xml @@ -0,0 +1,68 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-integration-test-csrf-reactive + Quarkus - Integration Tests - Cross-Site Request Forgery Filter Reactive + Module that contains Cross-Site Request Forgery Filter Reactive tests + + + + io.quarkus + quarkus-csrf-reactive + + + io.quarkus + quarkus-junit5 + test + + + net.sourceforge.htmlunit + htmlunit + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-csrf-reactive-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + 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 new file mode 100644 index 0000000000000..3e836c35f093c --- /dev/null +++ b/integration-tests/csrf-reactive/src/main/java/io/quarkus/it/csrf/TestResource.java @@ -0,0 +1,46 @@ +package io.quarkus.it.csrf; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +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.MediaType; + +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import io.vertx.ext.web.RoutingContext; + +@Path("/service") +public class TestResource { + + @Inject + Template csrfToken; + + @Inject + RoutingContext routingContext; + + @GET + @Path("/csrfTokenForm") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance getCsrfTokenForm() { + return csrfToken.instance(); + } + + @POST + @Path("/csrfTokenForm") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + public String postCsrfTokenForm(@FormParam("name") String name) { + return name + ":" + routingContext.get("csrf_token_verified", false); + } + + @GET + @Path("/hello") + @Produces(MediaType.TEXT_PLAIN) + public String getSimpleGet() { + return "hello"; + } +} diff --git a/integration-tests/csrf-reactive/src/main/resources/application.properties b/integration-tests/csrf-reactive/src/main/resources/application.properties new file mode 100644 index 0000000000000..7eb09958a644e --- /dev/null +++ b/integration-tests/csrf-reactive/src/main/resources/application.properties @@ -0,0 +1,2 @@ +quarkus.csrf-reactive.cookie-name=csrftoken +quarkus.csrf-reactive.create-token-path=/service/csrfTokenForm diff --git a/integration-tests/csrf-reactive/src/main/resources/templates/csrfToken.html b/integration-tests/csrf-reactive/src/main/resources/templates/csrfToken.html new file mode 100644 index 0000000000000..1952079e6874e --- /dev/null +++ b/integration-tests/csrf-reactive/src/main/resources/templates/csrfToken.html @@ -0,0 +1,17 @@ + + + + +CSRF Token Test + + +

CSRF Test

+ +
+ + +

Your Name:

+

+
+ + diff --git a/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveITCase.java b/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveITCase.java new file mode 100644 index 0000000000000..a26868d9c8c13 --- /dev/null +++ b/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveITCase.java @@ -0,0 +1,7 @@ +package io.quarkus.it.csrf; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class CsrfReactiveITCase extends CsrfReactiveTest { +} 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 new file mode 100644 index 0000000000000..84359494b1955 --- /dev/null +++ b/integration-tests/csrf-reactive/src/test/java/io/quarkus/it/csrf/CsrfReactiveTest.java @@ -0,0 +1,130 @@ +package io.quarkus.it.csrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; + +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.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.util.Cookie; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +public class CsrfReactiveTest { + + @Test + public void testCsrfToken() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm"); + + assertEquals("CSRF Token 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", textPage.getContent()); + + textPage = webClient.getPage("http://localhost:8081/service/hello"); + assertEquals("hello", textPage.getContent()); + + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testCsrfTokenInFormButNoCookie() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm"); + + assertEquals("CSRF Token Test", htmlPage.getTitleText()); + + HtmlForm loginForm = htmlPage.getForms().get(0); + + loginForm.getInputByName("name").setValueAttribute("alice"); + assertNotNull(webClient.getCookieManager().getCookie("csrftoken")); + + webClient.getCookieManager().clearCookies(); + + assertNull(webClient.getCookieManager().getCookie("csrftoken")); + try { + loginForm.getInputByName("submit").click(); + fail("400 status error is expected"); + } catch (FailingHttpStatusCodeException ex) { + assertEquals(400, ex.getStatusCode()); + } + 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()); + + HtmlForm loginForm = htmlPage.getForms().get(0); + + loginForm.getInputByName("name").setValueAttribute("alice"); + assertNotNull(webClient.getCookieManager().getCookie("csrftoken")); + + webClient.getCookieManager().clearCookies(); + + assertNull(webClient.getCookieManager().getCookie("csrftoken")); + + webClient.getCookieManager().addCookie(new Cookie("localhost", "csrftoken", "wrongvalue")); + + assertNotNull(webClient.getCookieManager().getCookie("csrftoken")); + try { + loginForm.getInputByName("submit").click(); + fail("400 status error is expected"); + } catch (FailingHttpStatusCodeException ex) { + assertEquals(400, ex.getStatusCode()); + } + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testWrongCsrfTokenFormValue() throws Exception { + try (final WebClient webClient = createWebClient()) { + + HtmlPage htmlPage = webClient.getPage("http://localhost:8081/service/csrfTokenForm"); + + assertEquals("CSRF Token Test", htmlPage.getTitleText()); + + assertNotNull(webClient.getCookieManager().getCookie("csrftoken")); + + RestAssured.given().urlEncodingEnabled(true) + .param("csrf-token", "wrong-value") + .post("/service/csrfTokenForm") + .then().statusCode(400); + + webClient.getCookieManager().clearCookies(); + } + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index cc25f038c085e..a408c41796ba6 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -228,6 +228,7 @@ oidc-tenancy oidc-wiremock keycloak-authorization + csrf-reactive reactive-db2-client reactive-pg-client reactive-mysql-client