Skip to content

Instantly share code, notes, and snippets.

@kekbur
Last active March 10, 2022 21:29
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kekbur/b6fa75cfe52846a08143703ec2cf13e0 to your computer and use it in GitHub Desktop.
Save kekbur/b6fa75cfe52846a08143703ec2cf13e0 to your computer and use it in GitHub Desktop.
Quarkus CSRF token filter
/**
* The MIT License
* Copyright (c) 2020 kekbur
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Optional;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.inject.Named;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.NotSupportedException;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.ext.Provider;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.jboss.logging.Logger;
import io.vertx.core.http.Cookie;
import io.vertx.ext.web.RoutingContext;
@Provider
public class CSRFFilter implements ContainerRequestFilter, ContainerResponseFilter
{
private static final Logger LOG = Logger.getLogger(CSRFFilter.class);
private static final String CSRF_TOKEN_KEY = "csrf-token";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* The random token size in bytes.
*/
private static final int TOKEN_SIZE = 16;
/**
* The maximum size of the request entity buffer in bytes.
*/
private static final int ENTITY_BUFFER_SIZE = 1024 * 64;
@Context
RoutingContext routing;
/**
* Gets the CSRF token from the cookie named {@value #CSRF_TOKEN_KEY} 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 Optional<String> getCookieToken()
{
Cookie cookie = routing.getCookie(CSRF_TOKEN_KEY);
if (cookie == null || cookie.getValue() == null)
{
LOG.debug("CSRF token cookie is not set");
return Optional.empty();
}
String token = cookie.getValue();
try
{
int suppliedTokenSize = Hex.decodeHex(token).length;
if (suppliedTokenSize != TOKEN_SIZE)
{
LOG.debugf("Invalid CSRF token cookie size: expected %d, got %d", TOKEN_SIZE, suppliedTokenSize);
return Optional.empty();
}
}
catch (DecoderException e)
{
LOG.debugf("Invalid CSRF token cookie: %s", token);
return Optional.empty();
}
return Optional.of(token);
}
/**
* If the request method is safe ({@code GET}, {@code HEAD} or {@code OPTIONS}):
* <ul><li>Sets a {@link RoutingContext} key by the name {@value #CSRF_TOKEN_KEY} that contains a randomly generated hex string, unless such a cookie was already sent in the incoming request.</li></ul>
* If the request method is unsafe, requires the following:
* <ul>
* <li>The request contains a valid CSRF token cookie set in response to a previous request (see above).</li>
* <li>A request entity is present.</li>
* <li>The request {@code Content-Type} is {@value MediaType#APPLICATION_FORM_URLENCODED}.</li>
* <li>The first {@value #ENTITY_BUFFER_SIZE} bytes of the request entity contain a form parameter with the name {@value #CSRF_TOKEN_KEY} and value that is equal to the one supplied in the cookie.</li>
* </ul>
* @throws NotSupportedException if the above request media type requirement is not met
* @throws BadRequestException if some other requirement above is not met
*/
@Override
public void filter(ContainerRequestContext context)
{
Optional<String> cookieToken = getCookieToken();
cookieToken.ifPresent(token -> routing.put(CSRF_TOKEN_KEY, token));
if (requestMethodIsSafe(context))
{
// safe HTTP method, tolerate the absence of a token
if (cookieToken.isEmpty())
{
// Set the CSRF cookie with a randomly generated value
byte[] token = new byte[TOKEN_SIZE];
SECURE_RANDOM.nextBytes(token);
routing.put(CSRF_TOKEN_KEY, Hex.encodeHexString(token));
}
}
else
{
// unsafe HTTP method, token is required
if (!context.hasEntity())
{
LOG.debug("Request has no entity");
throw new BadRequestException("Invalid CSRF token");
}
if (!context.getMediaType().getType().equals(MediaType.APPLICATION_FORM_URLENCODED_TYPE.getType()) || !context.getMediaType().getSubtype().equals(MediaType.APPLICATION_FORM_URLENCODED_TYPE.getSubtype()))
{
LOG.debugf("Request has the wrong media type: %s", context.getMediaType().toString());
throw new NotSupportedException("The only supported media type is " + MediaType.APPLICATION_FORM_URLENCODED);
}
String expectedToken = cookieToken.orElseThrow(() ->
new BadRequestException("Invalid CSRF token"));
InputStream input = context.getEntityStream();
String needle = CSRF_TOKEN_KEY + "=" + expectedToken;
if (!input.markSupported())
{
input = new BufferedInputStream(input);
context.setEntityStream(input);
}
input.mark(ENTITY_BUFFER_SIZE);
try
{
if (!contains(input, needle.getBytes(StandardCharsets.ISO_8859_1)))
{
LOG.debugf("CSRF token not found within the first %d bytes of the request entity", ENTITY_BUFFER_SIZE);
throw new BadRequestException("Invalid CSRF token");
}
input.reset();
}
catch (IOException e)
{
throw new InternalServerErrorException(e);
}
}
}
/**
* If the requirements below are true, sets a cookie by the name {@value #CSRF_TOKEN_KEY} that contains a CSRF token.
* <ul>
* <li>The request method is {@code GET}.</li>
* <li>The request does not contain a valid CSRF token cookie.</li>
* </ul>
* @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.
*/
@Override
public void filter(ContainerRequestContext request, ContainerResponseContext response) throws IOException
{
if (request.getMethod().equals("GET") && getCookieToken().isEmpty())
{
String token = (String) routing.get(CSRF_TOKEN_KEY);
if (token == null)
{
throw new IllegalStateException("CSRFFilter should have set the property " + CSRF_TOKEN_KEY + ", but it is null");
}
routing.addCookie(Cookie.cookie(CSRF_TOKEN_KEY, token));
}
}
private static boolean requestMethodIsSafe(ContainerRequestContext context)
{
switch (context.getMethod())
{
case "GET":
case "HEAD":
case "OPTIONS":
return true;
default:
return false;
}
}
/**
* Tells whether the supplied input stream contains the supplied byte array in the first {@link ENTITY_BUFFER_SIZE} bytes.
*/
static boolean contains(InputStream input, byte[] needle) throws IOException
{
byte[] buffer = new byte[1024 * 2];
int read = 0;
int inputOffset = 0;
int needleIndex = 0;
int readLength = Math.min(buffer.length, ENTITY_BUFFER_SIZE - inputOffset);
while ((read = input.read(buffer, 0, readLength)) != -1)
{
for (int bufferIndex = 0; bufferIndex < read; bufferIndex++)
{
if (needle[needleIndex] == buffer[bufferIndex])
{
needleIndex++;
}
else
{
needleIndex = 0;
}
if (needleIndex == needle.length)
{
return true;
}
}
inputOffset += read;
readLength = Math.min(buffer.length, ENTITY_BUFFER_SIZE - inputOffset);
if (readLength < 1)
{
break;
}
}
return false;
}
@ApplicationScoped
@Named("csrf")
static class CSRFTokenProvider
{
@Inject
RoutingContext context;
/**
* 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 " + CSRF_TOKEN_KEY + ", 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 CSRF_TOKEN_KEY;
}
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CSRF Test</title>
</head>
<body>
<h1>CSRF Test</h1>
<form action="/" 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" /></p>
</form>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment