Skip to content

Commit

Permalink
Support CSRF Request Header
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Jul 6, 2023
1 parent 6fde192 commit c2a83d2
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 30 deletions.
42 changes: 39 additions & 3 deletions docs/src/main/asciidoc/security-csrf-prevention.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down Expand Up @@ -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]
----
<body hx-headers='{"{inject:csrf.headerName}":"{inject:csrf.token}"}'> <1>
</body>
----
<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:
Expand Down Expand Up @@ -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
----

Expand Down Expand Up @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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());
}
}

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

/**
Expand All @@ -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;
}
}
17 changes: 17 additions & 0 deletions integration-tests/csrf-reactive/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-csrf-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-elytron-security-properties-file</artifactId>
Expand Down Expand Up @@ -51,6 +55,19 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive-jackson-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-elytron-security-properties-file-deployment</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public class TestResource {
@Inject
Template csrfTokenForm;

@Inject
Template csrfTokenHeader;

@Inject
Template csrfTokenWithFormRead;

Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CSRF Token Header Test</title>
</head>
<body>
<h1>CSRF Test</h1>

<input name="{inject:csrf.headerName}" value="{inject:csrf.token}" />

</body>
</html>
Loading

0 comments on commit c2a83d2

Please sign in to comment.