From 0db6a7b9440b8d3eb9f78dd311a425103d44cafc Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 27 Feb 2026 09:09:07 +0100 Subject: [PATCH 1/2] feat: Implement session-cookie API authentication hardening - Added CSRF token management to DataverseSession for session-cookie authentication. - Introduced new feature flag `dataverse.feature.api-session-auth-hardening` to enable CSRF protections. - Enhanced AuthFilter to validate CSRF tokens and request origins for state-changing API calls. - Updated CompoundAuthMechanism to tag authentication mechanisms in requests. - Created new endpoint `GET /api/users/:csrf-token` for clients to retrieve CSRF tokens. - Added tests for new functionality and updated existing tests to cover new behavior. - Documented changes in release notes for clarity on new security features. --- .../12178-session-cookie-api-hardening.md | 22 + doc/sphinx-guides/source/api/native-api.rst | 34 +- .../source/installation/config.rst | 30 +- .../iq/dataverse/DataverseSession.java | 22 + .../iq/dataverse/api/ApiConstants.java | 8 + .../edu/harvard/iq/dataverse/api/Users.java | 27 ++ .../iq/dataverse/api/auth/AuthFilter.java | 176 +++++++ .../api/auth/CompoundAuthMechanism.java | 22 + .../api/auth/SessionCookieAuthMechanism.java | 3 +- .../iq/dataverse/settings/FeatureFlags.java | 15 +- .../iq/dataverse/DataverseSessionTest.java | 31 ++ .../iq/dataverse/api/auth/AuthFilterTest.java | 435 ++++++++++++++++++ .../api/auth/CompoundAuthMechanismTest.java | 41 +- .../auth/SessionCookieAuthMechanismTest.java | 13 + 14 files changed, 869 insertions(+), 10 deletions(-) create mode 100644 doc/release-notes/12178-session-cookie-api-hardening.md create mode 100644 src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md new file mode 100644 index 00000000000..22faa55be6e --- /dev/null +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -0,0 +1,22 @@ +Session-cookie API authentication now has an opt-in hardening track controlled by a new feature flag: `dataverse.feature.api-session-auth-hardening` (requires `dataverse.feature.api-session-auth`). + +When hardening is enabled, Dataverse adds these protections for requests authenticated via session cookie: + +- Auth-mechanism-aware request tagging in the API auth flow. +- Origin/Referer validation and `X-Dataverse-CSRF-Token` checks for state-changing API calls. +- The same CSRF/origin checks for two known mutating `GET` endpoints: + - `/api/datasets/{id}/uploadurls` + - `/api/datasets/{id}/cleanStorage` +- `/api/access/*` guardrails for session-cookie auth: + - Read-oriented access remains allowed for compatibility. + - `POST /api/access/datafiles` remains allowed with same-origin validation. + - Other mutating `/api/access/*` endpoints are blocked for session-cookie auth. + +A new endpoint is available for session-cookie clients to fetch the CSRF token when hardening is enabled: + +- `GET /api/users/:csrf-token` + +Documentation updates: + +- Installation guide: feature flag behavior and deployment guidance. +- Native API guide: `GET /api/users/:csrf-token` usage and `X-Dataverse-CSRF-Token` header expectations. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 2befaa56b0c..c3a89966aa0 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6040,8 +6040,37 @@ Delete a Token In order to delete a token use:: curl -H "X-Dataverse-key:$API_TOKEN" -X DELETE "$SERVER_URL/api/users/token" - - + +Get CSRF Token for Session-Cookie API Auth +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When both ``dataverse.feature.api-session-auth`` and +``dataverse.feature.api-session-auth-hardening`` are enabled, clients using +session-cookie API authentication can fetch a CSRF token from this endpoint:: + + GET /api/users/:csrf-token + +This endpoint requires an authenticated session-cookie request and returns a +token that must be sent in the ``X-Dataverse-CSRF-Token`` header for +state-changing API requests protected by the hardening rules. + +Example:: + + curl -b cookies.txt "$SERVER_URL/api/users/:csrf-token" + +Example response:: + + { + "status": "OK", + "data": { + "csrfToken": "9f2a8f8c-7e1f-4bf1-8fd6-4c3e3b522f3f" + } + } + +To use this token in a subsequent API request:: + + curl -b cookies.txt -H "Origin:$SERVER_URL" -H "X-Dataverse-CSRF-Token:$CSRF_TOKEN" -X POST "$SERVER_URL/api/datasets/$ID/actions/:publish?type=minor" + Builtin Users ------------- @@ -8794,4 +8823,3 @@ A curl example listing collections: curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList" curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList?userIdentifier=anotherUser" - diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index d84d0bc625a..98efe4959f4 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3898,7 +3898,35 @@ To check the status of feature flags via API, see :ref:`list-all-feature-flags` dataverse.feature.api-session-auth ++++++++++++++++++++++++++++++++++ -Enables API authentication via session cookie (JSESSIONID). **Caution: Enabling this feature flag exposes the installation to CSRF risks!** We expect this feature flag to be temporary (only used by frontend developers, see `#9063 `_) and for the feature to be removed in the future. +Enables API authentication via session cookie (JSESSIONID). This is needed for some JSF/SAML-oriented integrations where bearer tokens are not used. +By itself, this feature flag does not enable CSRF protections. For stricter protections, also enable :ref:`dataverse.feature.api-session-auth-hardening`. + +.. _dataverse.feature.api-session-auth-hardening: + +dataverse.feature.api-session-auth-hardening ++++++++++++++++++++++++++++++++++++++++++++ + +Enables additional hardening for session-cookie API usage. This flag only has an effect when ``dataverse.feature.api-session-auth`` is also enabled. +The rules are based on request authentication mechanism (session cookie), not on the identity provider used to create the session +(``builtin``, Shibboleth, OAuth, OIDC, etc.). + +When enabled, Dataverse applies these protections for requests authenticated via session cookie: + +- Keeps read-oriented ``/api/access/*`` usage compatible with JSF downloads/previews. +- For ``POST /api/access/datafiles`` (batch download), requires same-origin Origin/Referer validation. +- Blocks session-cookie auth access to mutating ``/api/access/*`` endpoints (except the batch download POST above). +- Requires strict Origin/Referer validation plus the ``X-Dataverse-CSRF-Token`` header on: + - state-changing API calls (``POST``, ``PUT``, ``PATCH``, ``DELETE``) outside the ``/api/access`` compatibility rules above, + - and the two known mutating ``GET`` calls: + ``/api/datasets/{id}/uploadurls`` and ``/api/datasets/{id}/cleanStorage``. +- Exposes ``/api/users/:csrf-token`` for authenticated session-cookie clients to retrieve the CSRF token. + +Session-cookie hardening deployment guidance: + +- Use HTTPS end-to-end (or trusted TLS termination before Dataverse). +- Ensure JSESSIONID cookies are set with ``Secure`` and ``HttpOnly``. +- Use ``SameSite=Lax`` (recommended default) or ``SameSite=Strict`` if your login/redirect flow supports it. + ``SameSite=Strict`` can break some cross-site IdP/login return flows. .. _dataverse.feature.api-bearer-auth: diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index 607bc9d5a47..e475472d078 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.UUID; import java.util.logging.Logger; import jakarta.ejb.EJB; import jakarta.enterprise.context.SessionScoped; @@ -88,6 +89,7 @@ public void setDismissedMessages(List dismissedMessages) { * leave the state alone (see setDebug()). */ private Boolean debug; + private String apiCsrfToken; public User getUser() { return getUser(false); @@ -122,6 +124,25 @@ public User getUser(boolean lookupAuthenticatedUserAgain) { return user; } + /** + * Returns a CSRF token scoped to the current Dataverse session. + * The token is lazily created and reused until it is explicitly cleared. + */ + public String getOrCreateApiCsrfToken() { + if (apiCsrfToken == null || apiCsrfToken.isBlank()) { + apiCsrfToken = UUID.randomUUID().toString(); + } + return apiCsrfToken; + } + + public boolean matchesApiCsrfToken(String token) { + return token != null && apiCsrfToken != null && apiCsrfToken.equals(token); + } + + public void clearApiCsrfToken() { + apiCsrfToken = null; + } + /** * Sets the user and configures the session timeout. */ @@ -136,6 +157,7 @@ public void setUser(User aUser) { JsfHelper.addErrorMessage(BundleUtil.getStringFromBundle("deactivated.error")); return; } + clearApiCsrfToken(); FacesContext context = FacesContext.getCurrentInstance(); // Log the login/logout and Change the session id if we're using the UI and have // a session, versus an API call with no session - (i.e. /admin/submitToArchive() diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java index 15114085c21..d249d3344eb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java @@ -12,6 +12,14 @@ private ApiConstants() { // Authentication public static final String CONTAINER_REQUEST_CONTEXT_USER = "user"; + public static final String CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM = "authMechanism"; + public static final String AUTH_MECHANISM_NONE = "none"; + public static final String AUTH_MECHANISM_API_KEY = "apiKey"; + public static final String AUTH_MECHANISM_WORKFLOW_KEY = "workflowKey"; + public static final String AUTH_MECHANISM_SIGNED_URL = "signedUrl"; + public static final String AUTH_MECHANISM_BEARER_TOKEN = "bearerToken"; + public static final String AUTH_MECHANISM_SESSION_COOKIE = "sessionCookie"; + public static final String CSRF_TOKEN_HEADER = "X-Dataverse-CSRF-Token"; // Dataset public static final String DS_VERSION_LATEST = ":latest"; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index af6b533d46d..9bccf23bee3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -6,6 +6,7 @@ package edu.harvard.iq.dataverse.api; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -30,6 +31,8 @@ import edu.harvard.iq.dataverse.util.json.JsonPrinter; import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; +import jakarta.inject.Inject; +import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; @@ -47,6 +50,9 @@ public class Users extends AbstractApiBean { private static final Logger logger = Logger.getLogger(Users.class.getName()); + + @Inject + private DataverseSession session; @POST @AuthRequired @@ -208,6 +214,27 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc return ok(json(authenticatedUser)); } + @GET + @AuthRequired + @Path(":csrf-token") + public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return error(Response.Status.BAD_REQUEST, "Session-auth hardening is disabled."); + } + Object authMechanism = crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM); + if (!ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism)) { + return error( + Response.Status.FORBIDDEN, + "CSRF token endpoint is only available for session-cookie authentication."); + } + try { + getRequestAuthenticatedUserOrDie(crc); + return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken())); + } catch (WrappedResponse wr) { + return wr.getResponse(); + } + } + @POST @AuthRequired @Path("{identifier}/removeRoles") diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java index 34a72d718f0..2b0c4231497 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java @@ -1,7 +1,10 @@ package edu.harvard.iq.dataverse.api.auth; +import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.SystemConfig; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -10,6 +13,11 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.ext.Provider; import java.io.IOException; +import java.net.URI; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.logging.Logger; /** * @author Guillermo Portas @@ -20,16 +28,184 @@ @Priority(Priorities.AUTHENTICATION) public class AuthFilter implements ContainerRequestFilter { + private static final Logger logger = Logger.getLogger(AuthFilter.class.getCanonicalName()); + private static final Set STATE_CHANGING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); + private static final Set SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS"); + // These patterns intentionally target exact dataset API paths in Datasets.java. + // We use matcher.matches() against a normalized path (query string removed by JAX-RS). + private static final Pattern MUTATING_UPLOAD_URLS_GET_PATTERN = Pattern.compile("datasets/[^/]+/uploadurls"); + private static final Pattern MUTATING_CLEAN_STORAGE_GET_PATTERN = Pattern.compile("datasets/[^/]+/cleanStorage"); + private static final String ACCESS_BATCH_DOWNLOAD_POST_PATH = "access/datafiles"; + @Inject private CompoundAuthMechanism compoundAuthMechanism; + @Inject + private DataverseSession session; + + @Inject + private SystemConfig systemConfig; + @Override public void filter(ContainerRequestContext containerRequestContext) throws IOException { try { User user = compoundAuthMechanism.findUserFromRequest(containerRequestContext); containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + applySessionAuthHardening(containerRequestContext); } catch (WrappedAuthErrorResponse e) { containerRequestContext.abortWith(e.getResponse()); } } + + private void applySessionAuthHardening(ContainerRequestContext containerRequestContext) + throws WrappedAuthErrorResponse { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return; + } + if (!isSessionCookieRequest(containerRequestContext)) { + return; + } + + String path = normalizedPath(containerRequestContext); + if (isAccessPath(path)) { + applyAccessEndpointHardening(containerRequestContext, path); + return; + } + if (!requiresCsrfChecks(containerRequestContext, path)) { + return; + } + if (!isOriginOrRefererAllowed(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Request origin validation failed for session-cookie authentication."); + } + if (!isCsrfTokenValid(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Missing or invalid CSRF token for session-cookie authentication."); + } + } + + private boolean isSessionCookieRequest(ContainerRequestContext containerRequestContext) { + Object authMechanism = containerRequestContext + .getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM); + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism); + } + + private boolean requiresCsrfChecks(ContainerRequestContext containerRequestContext, String path) { + String method = containerRequestContext.getMethod(); + if (method == null) { + return false; + } + String normalizedMethod = method.toUpperCase(Locale.ROOT); + if (STATE_CHANGING_METHODS.contains(normalizedMethod)) { + return true; + } + if (!"GET".equals(normalizedMethod)) { + return false; + } + return MUTATING_UPLOAD_URLS_GET_PATTERN.matcher(path).matches() + || MUTATING_CLEAN_STORAGE_GET_PATTERN.matcher(path).matches(); + } + + private void applyAccessEndpointHardening(ContainerRequestContext containerRequestContext, String path) + throws WrappedAuthErrorResponse { + String method = containerRequestContext.getMethod(); + String normalizedMethod = method == null ? "" : method.toUpperCase(Locale.ROOT); + if (SAFE_METHODS.contains(normalizedMethod)) { + return; + } + if ("POST".equals(normalizedMethod) && ACCESS_BATCH_DOWNLOAD_POST_PATH.equals(path)) { + if (!isOriginOrRefererAllowed(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Request origin validation failed for session-cookie batch downloads."); + } + return; + } + throw new WrappedForbiddenAuthErrorResponse( + "Session-cookie authentication is not allowed for mutating /api/access endpoints when " + + "dataverse.feature.api-session-auth-hardening is enabled."); + } + + private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { + String allowedOrigin = toOrigin(systemConfig.getDataverseSiteUrl()); + if (allowedOrigin == null) { + logger.warning("Unable to validate Origin/Referer for session hardening: dataverse site URL is invalid."); + return false; + } + String originHeader = containerRequestContext.getHeaderString("Origin"); + String refererHeader = containerRequestContext.getHeaderString("Referer"); + boolean hasOrigin = originHeader != null && !originHeader.isBlank(); + boolean hasReferer = refererHeader != null && !refererHeader.isBlank(); + + if (!hasOrigin && !hasReferer) { + return false; + } + if (hasOrigin && !allowedOrigin.equals(toOrigin(originHeader))) { + return false; + } + if (hasReferer && !allowedOrigin.equals(toOrigin(refererHeader))) { + return false; + } + return true; + } + + private boolean isCsrfTokenValid(ContainerRequestContext containerRequestContext) { + String requestToken = containerRequestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER); + return requestToken != null && !requestToken.isBlank() && session.matchesApiCsrfToken(requestToken); + } + + private String normalizedPath(ContainerRequestContext containerRequestContext) { + String path = containerRequestContext.getUriInfo().getPath(); + if (path == null) { + return ""; + } + String normalized = path; + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (!normalized.isEmpty() && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if ("api".equals(normalized)) { + return ""; + } + if (normalized.startsWith("api/")) { + normalized = normalized.substring(4); + } + return normalized; + } + + private boolean isAccessPath(String path) { + return "access".equals(path) || path.startsWith("access/"); + } + + private String toOrigin(String url) { + if (url == null || url.isBlank()) { + return null; + } + try { + URI uri = URI.create(url.trim()); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost().toLowerCase(Locale.ROOT); + int port = uri.getPort(); + if (port == -1 || port == defaultPort(scheme)) { + return scheme + "://" + host; + } + return scheme + "://" + host + ":" + port; + } catch (IllegalArgumentException e) { + return null; + } + } + + private int defaultPort(String scheme) { + if ("http".equals(scheme)) { + return 80; + } + if ("https".equals(scheme)) { + return 443; + } + return -1; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java index e5be5144897..4c9a90d4327 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.api.auth; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -35,11 +36,13 @@ public void add(AuthMechanism... authMechanisms) { @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { + containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, ApiConstants.AUTH_MECHANISM_NONE); User user = null; for (AuthMechanism authMechanism : authMechanisms) { User userFromRequest = authMechanism.findUserFromRequest(containerRequestContext); if (userFromRequest != null) { user = userFromRequest; + containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, getAuthMechanismTag(authMechanism)); break; } } @@ -48,4 +51,23 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } return user; } + + private String getAuthMechanismTag(AuthMechanism authMechanism) { + if (authMechanism instanceof ApiKeyAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_API_KEY; + } + if (authMechanism instanceof WorkflowKeyAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_WORKFLOW_KEY; + } + if (authMechanism instanceof SignedUrlAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_SIGNED_URL; + } + if (authMechanism instanceof BearerTokenAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_BEARER_TOKEN; + } + if (authMechanism instanceof SessionCookieAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE; + } + return ApiConstants.AUTH_MECHANISM_NONE; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java index c1471c3f5b3..3a1d93041bd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java @@ -14,7 +14,8 @@ public class SessionCookieAuthMechanism implements AuthMechanism { @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { if (FeatureFlags.API_SESSION_AUTH.enabled()) { - return session.getUser(); + User user = session.getUser(); + return user != null && user.isAuthenticated() ? user : null; } return null; } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index e1c7e69f7db..ac406c540e8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -25,11 +25,24 @@ public enum FeatureFlags { /** - * Enables API authentication via session cookie (JSESSIONID). Caution: Enabling this feature flag may expose the installation to CSRF risks + * Enables API authentication via session cookie (JSESSIONID). + * Needed for JSF/SAML-oriented integrations where bearer tokens are not used. + * By itself this flag does not enable CSRF protections; for stricter protections, + * also enable {@link #API_SESSION_AUTH_HARDENING}. + * * @apiNote Raise flag by setting "dataverse.feature.api-session-auth" * @since Dataverse 5.14 */ API_SESSION_AUTH("api-session-auth"), + /** + * Enables additional hardening for session-cookie API authentication. + * This includes CSRF protections and session-cookie-specific endpoint guardrails. + * This feature only works when the feature flag {@link #API_SESSION_AUTH} is also enabled. + * + * @apiNote Raise flag by setting "dataverse.feature.api-session-auth-hardening" + * @since Dataverse 6.9 + */ + API_SESSION_AUTH_HARDENING("api-session-auth-hardening"), /** * Enables API authentication via Bearer Token. * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth" diff --git a/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java new file mode 100644 index 00000000000..b8c85dae079 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java @@ -0,0 +1,31 @@ +package edu.harvard.iq.dataverse; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataverseSessionTest { + + @Test + void testCsrfTokenIsGeneratedAndReused() { + DataverseSession session = new DataverseSession(); + + String token1 = session.getOrCreateApiCsrfToken(); + String token2 = session.getOrCreateApiCsrfToken(); + + assertEquals(token1, token2); + assertTrue(session.matchesApiCsrfToken(token1)); + } + + @Test + void testCsrfTokenCanBeCleared() { + DataverseSession session = new DataverseSession(); + String token = session.getOrCreateApiCsrfToken(); + + session.clearApiCsrfToken(); + + assertFalse(session.matchesApiCsrfToken(token)); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java new file mode 100644 index 00000000000..e1331b7da70 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -0,0 +1,435 @@ +package edu.harvard.iq.dataverse.api.auth; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.ApiConstants; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@LocalJvmSettings +class AuthFilterTest { + + @Test + void testFilter_HardeningDisabled_DoesNotAbort() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + User user = new AuthenticatedUser(); + when(compound.findUserFromRequest(requestContext)).thenReturn(user); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + verify(requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessGetAllowedForJsfDownloads() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresSameOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPrefixAndTrailingSlashAllowed() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "api/access/datafiles/"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOriginBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://evil.example/malicious"); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("PUT", "access/datafile/1/requestAccess"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1/add"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_MutatingGetNeedsCsrfAndOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "datasets/1/uploadurls"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_CleanStorageMutatingGetNeedsCsrfAndOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "datasets/1/cleanStorage"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAccessPath() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, ApiConstants.AUTH_MECHANISM_API_KEY); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_API_KEY); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithNoOriginOrRefererBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1/add"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // No Origin or Referer headers + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithMissingCsrfTokenBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("DELETE", "datasets/1"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + // CSRF token header absent (returns null by default from mock) + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithWrongCsrfTokenBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("PUT", "datasets/1/editMetadata"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("wrong-token"); + when(session.matchesApiCsrfToken("wrong-token")).thenReturn(false); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + private ContainerRequestContext mockRequestContext(String method, String path) { + ContainerRequestContext requestContext = Mockito.mock(ContainerRequestContext.class); + UriInfo uriInfo = Mockito.mock(UriInfo.class); + when(requestContext.getMethod()).thenReturn(method); + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPath()).thenReturn(path); + return requestContext; + } + + private void inject(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java index b3435d53ca2..799bed15187 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java @@ -1,9 +1,13 @@ package edu.harvard.iq.dataverse.api.auth; -import edu.harvard.iq.dataverse.api.auth.doubles.ContainerRequestTestFake; +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -13,7 +17,10 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +@LocalJvmSettings public class CompoundAuthMechanismTest { @Test @@ -25,25 +32,51 @@ public void testFindUserFromRequest_CanNotAuthenticateUserWithAnyMechanism() thr Mockito.when(authMechanismStub2.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(null); CompoundAuthMechanism sut = new CompoundAuthMechanism(authMechanismStub1, authMechanismStub2); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); - User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + User actual = sut.findUserFromRequest(containerRequestContext); assertThat(actual, equalTo(GuestUser.get())); + verify(containerRequestContext, atLeastOnce()).setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_NONE); } @Test public void testFindUserFromRequest_UserAuthenticated() throws WrappedAuthErrorResponse { AuthMechanism authMechanismStub1 = Mockito.mock(AuthMechanism.class); AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(authMechanismStub1.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(testAuthenticatedUser); + Mockito.when(authMechanismStub1.findUserFromRequest(any(ContainerRequestContext.class))) + .thenReturn(testAuthenticatedUser); AuthMechanism authMechanismStub2 = Mockito.mock(AuthMechanism.class); Mockito.when(authMechanismStub2.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(null); CompoundAuthMechanism sut = new CompoundAuthMechanism(authMechanismStub1, authMechanismStub2); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); - User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + User actual = sut.findUserFromRequest(containerRequestContext); assertEquals(actual, testAuthenticatedUser); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth") + void testFindUserFromRequest_TagsSessionCookieMechanism() throws WrappedAuthErrorResponse { + SessionCookieAuthMechanism sessionCookieAuthMechanism = new SessionCookieAuthMechanism(); + DataverseSession dataverseSessionStub = Mockito.mock(DataverseSession.class); + AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); + Mockito.when(dataverseSessionStub.getUser()).thenReturn(testAuthenticatedUser); + sessionCookieAuthMechanism.session = dataverseSessionStub; + + CompoundAuthMechanism sut = new CompoundAuthMechanism(sessionCookieAuthMechanism); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); + + User actual = sut.findUserFromRequest(containerRequestContext); + + assertEquals(actual, testAuthenticatedUser); + verify(containerRequestContext).setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java index 74a7d239c05..4be29c32387 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.auth.doubles.ContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.testing.JvmSetting; @@ -46,4 +47,16 @@ void testFindUserFromRequest_FeatureFlagEnabled_UserAuthenticated() throws Wrapp assertEquals(testAuthenticatedUser, actual); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth") + void testFindUserFromRequest_FeatureFlagEnabled_GuestSessionUserReturnsNull() throws WrappedAuthErrorResponse { + DataverseSession dataverseSessionStub = Mockito.mock(DataverseSession.class); + Mockito.when(dataverseSessionStub.getUser()).thenReturn(GuestUser.get()); + sut.session = dataverseSessionStub; + + User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + + assertNull(actual); + } } From 966ec7cee0a7b4fc36c2f13d282fa34b25ec8b0d Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 27 Feb 2026 16:49:16 +0100 Subject: [PATCH 2/2] docs: add session-cookie hardening guidance and verification steps --- .../source/installation/config.rst | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 98efe4959f4..1071e704fcc 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -216,6 +216,7 @@ By default, Payara doesn't send the SameSite cookie attribute, which browsers sh Dataverse installations are explicity set to "Lax" out of the box by the installer (in the case of a "classic" installation) or through the base image (in the case of a Docker installation). For classic, see :ref:`http.cookie-same-site-value` and :ref:`http.cookie-same-site-enabled` for how to change the values. For Docker, you must rebuild the :doc:`base image `. See also Payara's `documentation `_ for the settings above. To inspect cookie attributes like SameSite, you can use ``curl -s -I http://localhost:8080 | grep JSESSIONID``, for example, looking for the "Set-Cookie" header. +For session-cookie API hardening guidance (including how to verify and set ``Secure``/``HttpOnly`` for ``JSESSIONID``), see :ref:`session-cookie-hardening-guidance`. .. _ongoing-security: @@ -3921,6 +3922,8 @@ When enabled, Dataverse applies these protections for requests authenticated via ``/api/datasets/{id}/uploadurls`` and ``/api/datasets/{id}/cleanStorage``. - Exposes ``/api/users/:csrf-token`` for authenticated session-cookie clients to retrieve the CSRF token. +.. _session-cookie-hardening-guidance: + Session-cookie hardening deployment guidance: - Use HTTPS end-to-end (or trusted TLS termination before Dataverse). @@ -3928,6 +3931,47 @@ Session-cookie hardening deployment guidance: - Use ``SameSite=Lax`` (recommended default) or ``SameSite=Strict`` if your login/redirect flow supports it. ``SameSite=Strict`` can break some cross-site IdP/login return flows. +How to verify and set ``JSESSIONID`` cookie flags (Payara) + +- Verify cookie flags from a response header: + + ``curl -s -I https:/// | grep -i "set-cookie: JSESSIONID"`` + + The ``Set-Cookie`` header should include ``HttpOnly``, ``Secure``, and your expected ``SameSite`` value. + +- Verify current Payara virtual-server settings: + + ``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-http-only"`` + + ``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-secure"`` + +- Set ``JSESSIONID`` flags on the default virtual server (``server``): + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-http-only=true`` + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-secure=true`` + +- If you use SSO cookie flows (``JSESSIONIDSSO``), set those too: + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-http-only=true`` + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-secure=true`` + +After changing these settings, restart Payara and re-check the response headers. + +Session-Cookie Hardening vs Bearer Token Auth + +- Session-cookie auth and bearer-token auth use different trust models: + - Session cookie (``JSESSIONID``) is automatically sent by browsers. + - Bearer token is sent only when the client explicitly includes it. +- Because of browser auto-send behavior, session-cookie auth requires anti-CSRF controls for state-changing API calls. + With this hardening track enabled, Dataverse enforces Origin/Referer and CSRF token checks, which brings session-cookie browser usage into a security posture comparable to bearer for first-party, same-origin UI calls. +- Bearer remains preferable for non-browser and cross-origin API clients. +- Neither model protects against stolen credentials by itself: + - session hijack (stolen ``JSESSIONID``), + - bearer-token theft. + For both, use HTTPS, secure cookie/token handling, short lifetimes where possible, and strong XSS prevention. + .. _dataverse.feature.api-bearer-auth: dataverse.feature.api-bearer-auth