Commit ec658765 authored by Martin Lowe's avatar Martin Lowe

Merge branch 'malowe/master/3' into 'master'

Add CSRF filter + response validation to the core Quarkus lib

See merge request !3
parents 82b48092 7dec6db7
package org.eclipsefoundation.core.exception;
/**
* Represents an unauthorized request with no redirect (standard UnauthorizedException gets routed
* to OIDC login page when active, which is not desired).
*
* @author Martin Lowe
*/
public class FinalUnauthorizedException extends RuntimeException {
public FinalUnauthorizedException(String message) {
super(message);
}
/** */
private static final long serialVersionUID = 1L;
}
package org.eclipsefoundation.core.helper;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.annotation.PostConstruct;
import javax.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipsefoundation.core.exception.FinalUnauthorizedException;
import org.eclipsefoundation.core.model.AdditionalUserData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.undertow.util.HexConverter;
/**
* Helper class for interacting with CSRF tokens within the server. Generates secure CSRF tokens and compares them to
* the copy that exists within the current session object.
*
* @author Martin Lowe
*
*/
@Singleton
public final class CSRFHelper {
public static final Logger LOGGER = LoggerFactory.getLogger(CSRFHelper.class);
public static final String CSRF_HEADER_NAME = "x-csrf-token";
@ConfigProperty(name = "security.token.salt", defaultValue = "short-salt")
String salt;
@ConfigProperty(name = "security.csrf.enabled", defaultValue = "false")
boolean csrfEnabled;
// cryptographically secure random number generator
private SecureRandom rnd;
@PostConstruct
void init() {
// create a secure Random impl using salt + timestamp bytes
rnd = new SecureRandom(Long.toString(System.currentTimeMillis()).getBytes());
}
/**
* Generate a new CSRF token that has been hardened to make it more difficult to predict.
*
* @return a cryptographically-secure CSRF token to use in a session.
*/
public String getNewCSRFToken() {
// use a random value salted with a configured static value
byte[] bytes = rnd.generateSeed(24);
String secureRnd = new String(bytes);
// create a secure random secret to embed in the user session
String preHash = secureRnd + salt;
// create new digest to hash the result
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Could not find SHA-256 algorithm to encode CSRF token", e);
}
// hash the results using the message digest
byte[] array = md.digest(preHash.getBytes());
// convert back to a hex string to act as a token
return HexConverter.convertToHexString(array);
}
/**
* Compares the passed CSRF token to the token for the current user session.
*
* @param aud session data for current user
* @param passedCSRF the passed CSRF header data
* @throws FinalUnauthorizedException when CSRF token is missing in the user data, the passed header value, or does
* not match
*/
public void compareCSRF(AdditionalUserData aud, String passedCSRF) {
if (csrfEnabled) {
LOGGER.debug("Comparing following tokens:\n{}\n{}", aud == null ? null : aud.getCsrf(), passedCSRF);
if (aud == null || aud.getCsrf() == null) {
throw new FinalUnauthorizedException(
"CSRF token not generated for current request and is required, refusing request");
} else if (passedCSRF == null) {
throw new FinalUnauthorizedException("No CSRF token passed for current request, refusing request");
} else if (!passedCSRF.equals(aud.getCsrf())) {
throw new FinalUnauthorizedException("CSRF tokens did not match, refusing request");
}
}
}
}
package org.eclipsefoundation.core.model;
import javax.enterprise.context.SessionScoped;
@SessionScoped
public class AdditionalUserData {
private String csrf;
/** @return the csrf */
public String getCsrf() {
return csrf;
}
/** @param csrf the csrf to set */
public void setCsrf(String csrf) {
this.csrf = csrf;
}
}
package org.eclipsefoundation.core.request;
import java.io.IOException;
import javax.inject.Inject;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipsefoundation.core.exception.FinalUnauthorizedException;
import org.eclipsefoundation.core.helper.CSRFHelper;
import org.eclipsefoundation.core.model.AdditionalUserData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.undertow.httpcore.HttpMethodNames;
/**
* Creates a security layer in front of mutation requests to require CSRF tokens (if enabled). This layer does not
* perform the check of the token in-case there are other conditions that would rebuff the request.
*
* @author Martin Lowe
*/
@Provider
public class CSRFSecurityFilter implements ContainerRequestFilter {
public static final Logger LOGGER = LoggerFactory.getLogger(CSRFSecurityFilter.class);
@ConfigProperty(name = "security.csrf.enabled", defaultValue = "false")
boolean csrfEnabled;
@Inject
CSRFHelper csrf;
@Inject
AdditionalUserData aud;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
if (csrfEnabled) {
// check if the HTTP method indicates a mutation
String method = requestContext.getMethod();
if (HttpMethodNames.DELETE.equals(method) || HttpMethodNames.POST.equals(method)
|| HttpMethodNames.PUT.equals(method)) {
// check csrf token presence (not value)
String token = requestContext.getHeaderString(CSRFHelper.CSRF_HEADER_NAME);
if (token == null || "".equals(token.trim())) {
throw new FinalUnauthorizedException("No CSRF token passed for mutation call, refusing connection");
} else {
// run comparison. If error, exception will be thrown
csrf.compareCSRF(aud, token);
}
}
}
}
}
/* Copyright (c) 2019 Eclipse Foundation and others.
* This program and the accompanying materials are made available
* under the terms of the Eclipse Public License 2.0
* which is available at http://www.eclipse.org/legal/epl-v20.html,
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipsefoundation.core.resource.mapper;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import org.eclipsefoundation.core.exception.FinalUnauthorizedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Exception mapper to allow 403 to be thrown past auth barrier. Typical unauthorized exceptions
* cause redirects through OIDC layers which isn't always wanted
*
* @author Martin Lowe
*/
@Provider
public class FinalUnauthorizedMapper implements ExceptionMapper<FinalUnauthorizedException> {
private static final Logger LOGGER = LoggerFactory.getLogger(FinalUnauthorizedMapper.class);
@Override
public Response toResponse(FinalUnauthorizedException exception) {
LOGGER.error(exception.getMessage(), exception);
// return an empty response with a server error response
return Response.status(403).build();
}
}
package org.eclipsefoundation.core.response;
import java.io.IOException;
import javax.inject.Inject;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipsefoundation.core.helper.CSRFHelper;
import org.eclipsefoundation.core.model.AdditionalUserData;
/**
* Injects the CSRF header token into the response when enabled for a server.
*
* @author Martin Lowe
*/
@Provider
public class CSRFHeaderFilter implements ContainerResponseFilter {
@ConfigProperty(name = "security.csrf.enabled", defaultValue = "false")
boolean csrfEnabled;
@Inject
CSRFHelper csrf;
@Inject
AdditionalUserData aud;
@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext)
throws IOException {
// only attach if CSRF is enabled for the current runtime
if (csrfEnabled) {
// generate a new token if none is yet present
if (aud.getCsrf() == null) {
aud.setCsrf(csrf.getNewCSRFToken());
}
// attach the current CSRF token as a header on the request
responseContext.getHeaders().add(CSRFHelper.CSRF_HEADER_NAME, aud.getCsrf());
}
}
}
## OAUTH CONFIG
quarkus.oauth2.enabled=true
quarkus.oauth2.enabled=false
quarkus.oauth2.introspection-url=http://accounts.eclipse.org/oauth2/introspect
eclipse.oauth.override=false
......
package org.eclipsefoundation.core.authenticated.helper;
import javax.inject.Inject;
import org.eclipsefoundation.core.exception.FinalUnauthorizedException;
import org.eclipsefoundation.core.helper.CSRFHelper;
import org.eclipsefoundation.core.model.AdditionalUserData;
import org.eclipsefoundation.core.test.AuthenticatedTestProfile;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
/**
* Test CSRF functionality from the helper directly using the authentication secured test profile.
*
* @author Martin Lowe
*/
@QuarkusTest
@TestProfile(AuthenticatedTestProfile.class)
class CSRFHelperTest {
@Inject
CSRFHelper csrf;
@Test
void compareCSRF_validToken() {
// generate a token to use in test
String csrfToken = csrf.getNewCSRFToken();
// create session object with given CSRF token
AdditionalUserData aud = new AdditionalUserData();
aud.setCsrf(csrfToken);
// this should not throw as the tokens match
Assertions.assertDoesNotThrow(() -> csrf.compareCSRF(aud, csrfToken));
}
@Test
void compareCSRF_invalidToken() {
// generate a token to use in test
String csrfToken = csrf.getNewCSRFToken();
// create session object with given CSRF token
AdditionalUserData aud = new AdditionalUserData();
aud.setCsrf(csrfToken);
// this should throw as the tokens are not the same
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, "some-other-value"));
}
@Test
void compareCSRF_noSubmittedToken() {
// generate a token to use in test
String csrfToken = csrf.getNewCSRFToken();
// create session object with given CSRF token
AdditionalUserData aud = new AdditionalUserData();
aud.setCsrf(csrfToken);
// this should throw as the tokens are not the same
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, null));
// reset token value as its cleared between requests
aud.setCsrf(csrfToken);
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, ""));
}
@Test
void compareCSRF_noGeneratedToken() {
// simulates a session object with no CSRF data (no previous calls)
AdditionalUserData aud = new AdditionalUserData();
String sampleCSRF = csrf.getNewCSRFToken();
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, null));
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, ""));
Assertions.assertThrows(FinalUnauthorizedException.class, () -> csrf.compareCSRF(aud, sampleCSRF));
}
}
package org.eclipsefoundation.core.authenticated.request;
import static io.restassured.RestAssured.given;
import org.eclipsefoundation.core.helper.CSRFHelper;
import org.eclipsefoundation.core.test.AuthenticatedTestProfile;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import io.restassured.filter.session.SessionFilter;
import io.restassured.response.Response;
/**
* Test the CSRF security filter which can block requests based on presence of CSRF token. This makes use of the
* authenticated test profile to reduce complexity of testing other facets of the core lib that have no interactions
* with security.
*
* @author Martin Lowe
*
*/
@QuarkusTest
@TestProfile(AuthenticatedTestProfile.class)
class CSRFSecurityFilterTest {
@Test
void validateNoToken() {
// expect rebuff as no CSRF token was passed
given().when().get("/test").then().statusCode(403);
given().when().post("/test").then().statusCode(403);
given().when().put("/test").then().statusCode(403);
given().when().delete("/test").then().statusCode(403);
}
@Test
void validateWrongToken() {
// do a good request to trigger the build of the header internally
given().when().get("/test/unguarded").then().statusCode(200);
// expect rebuff as no CSRF token was passed
given().header(CSRFHelper.CSRF_HEADER_NAME, "bad-header-value").when().get("/test").then().statusCode(403);
given().header(CSRFHelper.CSRF_HEADER_NAME, "bad-header-value").when().post("/test").then().statusCode(403);
given().header(CSRFHelper.CSRF_HEADER_NAME, "bad-header-value").when().put("/test").then().statusCode(403);
given().header(CSRFHelper.CSRF_HEADER_NAME, "bad-header-value").when().delete("/test").then().statusCode(403);
}
@Test
void validateRightCSRFToken() {
SessionFilter sessionFilter = new SessionFilter();
// do a good request to trigger the build of the header internally
Response r = given().filter(sessionFilter).when().get("/test/unguarded");
String expectedHeader = r.getHeader(CSRFHelper.CSRF_HEADER_NAME);
// expect rebuff as no CSRF token was passed
given().filter(sessionFilter).header(CSRFHelper.CSRF_HEADER_NAME, expectedHeader).when().post("/test").then()
.statusCode(200);
given().filter(sessionFilter).header(CSRFHelper.CSRF_HEADER_NAME, expectedHeader).when().delete("/test").then()
.statusCode(200);
given().filter(sessionFilter).header(CSRFHelper.CSRF_HEADER_NAME, expectedHeader).when().put("/test").then()
.statusCode(200);
given().filter(sessionFilter).header(CSRFHelper.CSRF_HEADER_NAME, expectedHeader).when().get("/test").then()
.statusCode(200);
}
}
package org.eclipsefoundation.core.config;
import javax.enterprise.context.ApplicationScoped;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
/**
* Custom override for test classes that ignores current login state and sets
* all users as admin always. This should only ever be used in testing.
*
* @author Martin Lowe
*/
@ApplicationScoped
public class MockRoleAugmentor implements SecurityIdentityAugmentor {
@Override
public int priority() {
return 0;
}
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
// create a new builder and copy principal, attributes, credentials and roles
// from the original
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder()
.setPrincipal(identity.getPrincipal()).addAttributes(identity.getAttributes())
.addCredentials(identity.getCredentials()).addRoles(identity.getRoles());
// add custom role source here
builder.addRole("marketplace_admin_access");
return context.runBlocking(builder::build);
}
}
\ No newline at end of file
/* Copyright (c) 2019 Eclipse Foundation and others.
* This program and the accompanying materials are made available
* under the terms of the Eclipse Public License 2.0
* which is available at http://www.eclipse.org/legal/epl-v20.html,
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipsefoundation.core.model;
/**
* Wraps RequestWrapper for access outside of request scope in tests.
*
* @author Martin Lowe
*/
public class RequestWrapperMock extends RequestWrapper {
}
package org.eclipsefoundation.core.test;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
/**
* Used to enable authentication profile in testing for tests that use this profile. Note that tests that use this
* profile should be grouped in a single package to ensure back to back runs to not increase test run time. More
* available on https://quarkus.io/blog/quarkus-test-profiles/.
*
* @author Martin Lowe
*/
public class AuthenticatedTestProfile implements QuarkusTestProfile {
// private immutable copy of the configs for auth state
private static final Map<String, String> CONFIG_OVERRIDES;
static {
Map<String, String> tmp = new HashMap<>();
tmp.put("quarkus.oauth2.enabled", "true");
tmp.put("security.csrf.enabled", "true");
tmp.put("security.token.salt", "sample-salt-value-64^%$6DG54$DG46%Eas6egf54s%1#g5");
CONFIG_OVERRIDES = Collections.unmodifiableMap(tmp);
}
@Override
public Map<String, String> getConfigOverrides() {
return CONFIG_OVERRIDES;
}
}
package org.eclipsefoundation.core.test;
import javax.inject.Inject;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.eclipsefoundation.core.helper.CSRFHelper;
import org.eclipsefoundation.core.model.AdditionalUserData;
/**
* Test resource for testing basic responses and security/authentication measures.
*
* @author Martin Lowe
*
*/
@Path("/test")
@Produces(MediaType.APPLICATION_JSON)
public class TestResource {
@Inject
CSRFHelper csrf;
@Inject
AdditionalUserData aud;
/**
* Basic sample GET call that can be gated through CSRF if enabled to protect the data further.
*
* @param passedCsrf the passed CSRF header value
* @return empty ok response if CSRF is disabled or properly passed, 403 response otherwise.
*/
@GET
public Response get(@HeaderParam(value = CSRFHelper.CSRF_HEADER_NAME) String passedCsrf) {
// check CSRF manually as its a get request
csrf.compareCSRF(aud, passedCsrf);
return Response.ok().build();
}
/**
* Basic sample GET call that is not gated through CSRF. This could represent a user data endpoint, or a straight
* CSRF endpoint to trigger the header to be returned if enabled.
*
* @return empty ok response
*/
@GET
@Path("unguarded")
public Response get() {
// check CSRF manually as its a get request
return Response.ok().build();
}
/**
* Basic POST call that can be used to assist in validating filters.
*
* @return empty ok response if CSRF is disabled or properly passed, 403 response otherwise.
*/
@POST
public Response post() {
return Response.ok().build();
}
/**
* Basic PUT call that can be used to assist in validating filters.