Last active
March 10, 2022 21:29
-
-
Save kekbur/b6fa75cfe52846a08143703ec2cf13e0 to your computer and use it in GitHub Desktop.
Quarkus CSRF token filter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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