From 0db6a7b9440b8d3eb9f78dd311a425103d44cafc Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 27 Feb 2026 09:09:07 +0100 Subject: [PATCH 01/16] 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 02/16] 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 From e8dca345824280d17be8021a032869112c10ee84 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 14:09:03 +0100 Subject: [PATCH 03/16] docs: improve clarity on session-cookie hardening and bearer token authentication --- doc/sphinx-guides/source/installation/config.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 1071e704fcc..d6ede15b284 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3961,16 +3961,16 @@ 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. +- Session-cookie auth and bearer-token auth use different trust models. Session cookie + (``JSESSIONID``) is automatically sent by browsers, while 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. +- Neither model protects against stolen credentials by itself (session hijack via stolen + ``JSESSIONID`` or bearer-token theft). For both, use HTTPS, secure cookie/token handling, short + lifetimes where possible, and strong XSS prevention. .. _dataverse.feature.api-bearer-auth: From a472682e18a5b9be29bedb0fbfc8d8a671449d3e Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 15:01:52 +0100 Subject: [PATCH 04/16] docs: build fix --- doc/sphinx-guides/source/installation/config.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 93e6cd3ac5d..97df1462d11 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3940,7 +3940,7 @@ By itself, this feature flag does not enable CSRF protections. For stricter prot .. _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 @@ -3953,13 +3953,13 @@ When enabled, Dataverse applies these protections for requests authenticated via - 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``. + - 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-guidance: -Session-cookie hardening deployment guidance: +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``. @@ -3967,6 +3967,7 @@ Session-cookie hardening deployment guidance: ``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: @@ -3995,6 +3996,7 @@ How to verify and set ``JSESSIONID`` cookie flags (Payara) 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, while bearer token is sent only when the From 4c95f07b9efa88252268a0d0ec6fe8a920592c44 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:03:49 +0100 Subject: [PATCH 05/16] Update src/main/java/edu/harvard/iq/dataverse/DataverseSession.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/edu/harvard/iq/dataverse/DataverseSession.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index e475472d078..6d36c63a56d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -128,18 +128,18 @@ public User getUser(boolean lookupAuthenticatedUserAgain) { * 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() { + public synchronized String getOrCreateApiCsrfToken() { if (apiCsrfToken == null || apiCsrfToken.isBlank()) { apiCsrfToken = UUID.randomUUID().toString(); } return apiCsrfToken; } - public boolean matchesApiCsrfToken(String token) { + public synchronized boolean matchesApiCsrfToken(String token) { return token != null && apiCsrfToken != null && apiCsrfToken.equals(token); } - public void clearApiCsrfToken() { + public synchronized void clearApiCsrfToken() { apiCsrfToken = null; } From 3ec18863bdabbf557d1f8aa016d2f71b406fb4a1 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 15:59:51 +0100 Subject: [PATCH 06/16] docs: update session-cookie API hardening details and CSRF validation requirements --- .../12178-session-cookie-api-hardening.md | 2 +- .../source/installation/config.rst | 5 ++- .../iq/dataverse/api/auth/AuthFilter.java | 15 ++++++-- .../iq/dataverse/api/auth/AuthFilterTest.java | 37 ++++++++++++++++++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md index 22faa55be6e..fbd5c1895f0 100644 --- a/doc/release-notes/12178-session-cookie-api-hardening.md +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -10,7 +10,7 @@ When hardening is enabled, Dataverse adds these protections for requests authent - `/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. + - Other mutating `/api/access/*` endpoints require same-origin validation plus CSRF token. A new endpoint is available for session-cookie clients to fetch the CSRF token when hardening is enabled: diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 97df1462d11..0f5e3d70666 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3950,9 +3950,10 @@ When enabled, Dataverse applies these protections for requests authenticated via - 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). +- For other mutating ``/api/access/*`` endpoints, requires same-origin Origin/Referer validation plus + the ``X-Dataverse-CSRF-Token`` header (same checks as all other state-changing API calls). - 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, + - state-changing API calls (``POST``, ``PUT``, ``PATCH``, ``DELETE``), - 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. 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 2b0c4231497..719b1708d38 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 @@ -113,6 +113,8 @@ private void applyAccessEndpointHardening(ContainerRequestContext containerReque if (SAFE_METHODS.contains(normalizedMethod)) { return; } + // Batch download POST only needs origin check (no CSRF token) since + // it returns a file stream and is the primary JSF download path. if ("POST".equals(normalizedMethod) && ACCESS_BATCH_DOWNLOAD_POST_PATH.equals(path)) { if (!isOriginOrRefererAllowed(containerRequestContext)) { throw new WrappedForbiddenAuthErrorResponse( @@ -120,9 +122,16 @@ private void applyAccessEndpointHardening(ContainerRequestContext containerReque } return; } - throw new WrappedForbiddenAuthErrorResponse( - "Session-cookie authentication is not allowed for mutating /api/access endpoints when " - + "dataverse.feature.api-session-auth-hardening is enabled."); + // All other mutating /api/access/* calls get the standard hardening: + // same-origin + CSRF token. + 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 isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { 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 index e1331b7da70..9fa00f65814 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -168,13 +168,14 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOrigin @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlocked() throws Exception { + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithOriginAndCsrf() 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(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { ContainerRequestContext crc = invocation.getArgument(0); crc.setProperty( @@ -184,6 +185,40 @@ void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlocked() th }); 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_SessionCookieMutatingAccessEndpointBlockedWithoutCsrf() 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(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"); + // No CSRF token header inject(sut, "compoundAuthMechanism", compound); inject(sut, "session", session); From 7681a116b9eeb1018a675c5a01f7564aefd58aee Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 16:17:53 +0100 Subject: [PATCH 07/16] docs: enhance session-cookie API hardening details and CSRF protection requirements --- .../12178-session-cookie-api-hardening.md | 17 ++-- .../source/installation/config.rst | 21 ++--- .../iq/dataverse/api/auth/AuthFilter.java | 86 ------------------ .../iq/dataverse/settings/FeatureFlags.java | 3 +- .../iq/dataverse/api/auth/AuthFilterTest.java | 90 +++++++------------ 5 files changed, 54 insertions(+), 163 deletions(-) diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md index fbd5c1895f0..8d2688d3fe3 100644 --- a/doc/release-notes/12178-session-cookie-api-hardening.md +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -1,16 +1,15 @@ 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: +When hardening is enabled, every API request authenticated via session cookie must include: + +- A valid same-origin `Origin` or `Referer` header. +- The `X-Dataverse-CSRF-Token` header matching the token from `GET /api/users/:csrf-token`. + +This applies uniformly to all HTTP methods and all API paths, with no exceptions. Clients not on the same origin should use bearer-token authentication instead. + +Additional changes: - 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 require same-origin validation plus CSRF token. A new endpoint is available for session-cookie clients to fetch the CSRF token when hardening is enabled: diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 0f5e3d70666..d9c07659aac 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3946,16 +3946,17 @@ Enables additional hardening for session-cookie API usage. This flag only has an 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. -- For other mutating ``/api/access/*`` endpoints, requires same-origin Origin/Referer validation plus - the ``X-Dataverse-CSRF-Token`` header (same checks as all other state-changing API calls). -- Requires strict Origin/Referer validation plus the ``X-Dataverse-CSRF-Token`` header on: - - state-changing API calls (``POST``, ``PUT``, ``PATCH``, ``DELETE``), - - 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. +When enabled, Dataverse requires **every** API request authenticated via session cookie to include: + +- A valid same-origin ``Origin`` or ``Referer`` header. +- The ``X-Dataverse-CSRF-Token`` header matching the token obtained from ``GET /api/users/:csrf-token``. + +This applies uniformly to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.) and all +API paths, with no per-endpoint exceptions. The simplicity is intentional: session-cookie API auth +is only used by same-origin front-end clients that always have the CSRF token available. +Some ``GET`` endpoints in the codebase have side effects, so exempting reads would leave gaps. + +Clients not on the same origin should use bearer-token authentication instead. .. _session-cookie-hardening-guidance: 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 719b1708d38..ce6cbdddad2 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 @@ -15,8 +15,6 @@ 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; /** @@ -29,13 +27,6 @@ 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; @@ -66,14 +57,6 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC 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."); @@ -90,50 +73,6 @@ private boolean isSessionCookieRequest(ContainerRequestContext containerRequestC 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; - } - // Batch download POST only needs origin check (no CSRF token) since - // it returns a file stream and is the primary JSF download path. - 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; - } - // All other mutating /api/access/* calls get the standard hardening: - // same-origin + CSRF token. - 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 isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { String allowedOrigin = toOrigin(systemConfig.getDataverseSiteUrl()); if (allowedOrigin == null) { @@ -162,31 +101,6 @@ private boolean isCsrfTokenValid(ContainerRequestContext containerRequestContext 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; 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 ac406c540e8..4454794f731 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -36,7 +36,8 @@ public enum FeatureFlags { API_SESSION_AUTH("api-session-auth"), /** * Enables additional hardening for session-cookie API authentication. - * This includes CSRF protections and session-cookie-specific endpoint guardrails. + * When enabled, every session-cookie API request must include a valid same-origin + * Origin/Referer header and the X-Dataverse-CSRF-Token header. * 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" 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 index 9fa00f65814..974c68723ee 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -48,41 +48,13 @@ void testFilter_HardeningDisabled_DoesNotAbort() throws Exception { @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieAccessGetAllowedForJsfDownloads() throws Exception { + void testFilter_HardeningEnabled_SessionCookieGetAllowedWithOriginAndCsrf() 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); @@ -93,7 +65,9 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresSam }); 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"); + 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); @@ -106,9 +80,9 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresSam @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPrefixAndTrailingSlashAllowed() throws Exception { + void testFilter_HardeningEnabled_SessionCookieGetBlockedWithoutOriginAndCsrf() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("POST", "api/access/datafiles/"); + 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); @@ -123,7 +97,7 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPref }); 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"); + // No Origin, Referer, or CSRF token inject(sut, "compoundAuthMechanism", compound); inject(sut, "session", session); @@ -131,12 +105,14 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPref sut.filter(requestContext); - verify(requestContext, never()).abortWith(any(Response.class)); + 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_SessionCookieAccessBatchDownloadPostCrossOriginBlocked() throws Exception { + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresOriginAndCsrf() throws Exception { AuthFilter sut = new AuthFilter(); ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); @@ -153,7 +129,9 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOrigin }); when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); - when(requestContext.getHeaderString("Referer")).thenReturn("https://evil.example/malicious"); + 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); @@ -161,16 +139,14 @@ void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOrigin sut.filter(requestContext); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + verify(requestContext, never()).abortWith(any(Response.class)); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithOriginAndCsrf() throws Exception { + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPrefixAndTrailingSlashAllowed() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("PUT", "access/datafile/1/requestAccess"); + 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); @@ -185,7 +161,7 @@ void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithO }); 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); @@ -200,9 +176,9 @@ void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithO @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlockedWithoutCsrf() throws Exception { + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOriginBlocked() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("PUT", "access/datafile/1/requestAccess"); + ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); DataverseSession session = Mockito.mock(DataverseSession.class); SystemConfig systemConfig = Mockito.mock(SystemConfig.class); @@ -217,8 +193,7 @@ void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlockedWitho }); when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); - when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); - // No CSRF token header + when(requestContext.getHeaderString("Referer")).thenReturn("https://evil.example/malicious"); inject(sut, "compoundAuthMechanism", compound); inject(sut, "session", session); @@ -233,9 +208,9 @@ void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlockedWitho @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Exception { + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithOriginAndCsrf() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1/add"); + 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); @@ -251,7 +226,6 @@ void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Ex 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); @@ -266,9 +240,9 @@ void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Ex @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_MutatingGetNeedsCsrfAndOrigin() throws Exception { + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlockedWithoutCsrf() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("GET", "datasets/1/uploadurls"); + 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); @@ -284,8 +258,7 @@ void testFilter_HardeningEnabled_MutatingGetNeedsCsrfAndOrigin() throws Exceptio 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); + // No CSRF token header inject(sut, "compoundAuthMechanism", compound); inject(sut, "session", session); @@ -293,14 +266,16 @@ void testFilter_HardeningEnabled_MutatingGetNeedsCsrfAndOrigin() throws Exceptio sut.filter(requestContext); - verify(requestContext, never()).abortWith(any(Response.class)); + 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_CleanStorageMutatingGetNeedsCsrfAndOrigin() throws Exception { + void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Exception { AuthFilter sut = new AuthFilter(); - ContainerRequestContext requestContext = mockRequestContext("GET", "datasets/1/cleanStorage"); + 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); @@ -316,6 +291,7 @@ void testFilter_HardeningEnabled_CleanStorageMutatingGetNeedsCsrfAndOrigin() thr 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); @@ -330,7 +306,7 @@ void testFilter_HardeningEnabled_CleanStorageMutatingGetNeedsCsrfAndOrigin() thr @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAccessPath() throws Exception { + void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAnyPath() throws Exception { AuthFilter sut = new AuthFilter(); ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); From 4215552826bdc05a01991cf598162046d06bdf96 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 16:28:24 +0100 Subject: [PATCH 08/16] docs: add CSRF warning for session cookie API authentication feature --- doc/sphinx-guides/source/installation/config.rst | 6 ++++++ .../edu/harvard/iq/dataverse/settings/FeatureFlags.java | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index d9c07659aac..cb4624ff468 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3935,6 +3935,12 @@ dataverse.feature.api-session-auth ++++++++++++++++++++++++++++++++++ Enables API authentication via session cookie (JSESSIONID). This is needed for some JSF/SAML-oriented integrations where bearer tokens are not used. + +.. warning:: + + Enabling this flag without also enabling :ref:`dataverse.feature.api-session-auth-hardening` exposes the installation to CSRF risks. + Always enable both flags together in production. + 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: 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 4454794f731..0e6f157ea69 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -27,6 +27,8 @@ public enum FeatureFlags { /** * Enables API authentication via session cookie (JSESSIONID). * Needed for JSF/SAML-oriented integrations where bearer tokens are not used. + *

Caution: Enabling this flag without also enabling + * {@link #API_SESSION_AUTH_HARDENING} exposes the installation to CSRF risks.

* By itself this flag does not enable CSRF protections; for stricter protections, * also enable {@link #API_SESSION_AUTH_HARDENING}. * @@ -41,7 +43,7 @@ public enum FeatureFlags { * 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 + * @since Dataverse 6.10 */ API_SESSION_AUTH_HARDENING("api-session-auth-hardening"), /** From 0fbb95570270574f028216e878c6d844ea6ee364 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:49:44 +0100 Subject: [PATCH 09/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../iq/dataverse/api/auth/AuthFilter.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 ce6cbdddad2..1bd363d1f28 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 @@ -61,6 +61,13 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC throw new WrappedForbiddenAuthErrorResponse( "Request origin validation failed for session-cookie authentication."); } + + // Allow the CSRF-token-issuing endpoint to be called without an existing CSRF header. + // This endpoint is used to bootstrap the CSRF token for the current authenticated session. + if (isCsrfTokenBootstrapEndpoint(containerRequestContext)) { + return; + } + if (!isCsrfTokenValid(containerRequestContext)) { throw new WrappedForbiddenAuthErrorResponse( "Missing or invalid CSRF token for session-cookie authentication."); @@ -73,6 +80,25 @@ private boolean isSessionCookieRequest(ContainerRequestContext containerRequestC return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism); } + /** + * Returns {@code true} if the current request targets the CSRF-token-issuing endpoint + * (e.g. GET /api/users/:csrf-token). For this endpoint we rely on same-origin checks + * plus the authenticated session cookie, and do not require an existing CSRF header. + */ + private boolean isCsrfTokenBootstrapEndpoint(ContainerRequestContext containerRequestContext) { + String path = containerRequestContext.getUriInfo().getPath(); + if (path == null) { + return false; + } + String normalizedPath = path.toLowerCase(Locale.ROOT); + + // Support common variants such as "api/users/:csrf-token" or "users/:csrf-token". + if ("api/users/:csrf-token".equals(normalizedPath) || "users/:csrf-token".equals(normalizedPath)) { + return true; + } + return normalizedPath.endsWith("/api/users/:csrf-token") || normalizedPath.endsWith("/users/:csrf-token"); + } + private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { String allowedOrigin = toOrigin(systemConfig.getDataverseSiteUrl()); if (allowedOrigin == null) { From 5ce97dde2196e9c178820de952989e16aba75c90 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:50:53 +0100 Subject: [PATCH 10/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/edu/harvard/iq/dataverse/api/Users.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 9bccf23bee3..6da5ad08fda 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -215,7 +215,6 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc } @GET - @AuthRequired @Path(":csrf-token") public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { @@ -227,12 +226,15 @@ public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { 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(); + + // Authenticate based on the current session without invoking AuthFilter, + // so that this endpoint can bootstrap the CSRF token for session-cookie auth. + User currentUser = session.getUser(); + if (!(currentUser instanceof AuthenticatedUser)) { + return error(Response.Status.UNAUTHORIZED, "User must be authenticated via session cookie."); } + + return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken())); } @POST From 27eec9c5938a19fe5870941c7ee8134568fda6a5 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:51:28 +0100 Subject: [PATCH 11/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/sphinx-guides/source/api/native-api.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index d83e52aa713..4cf068e948e 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6170,8 +6170,9 @@ 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. +token that must be sent in the ``X-Dataverse-CSRF-Token`` header for all +subsequent session-cookie API requests protected by the hardening rules. +These hardened requests must also include an ``Origin`` or ``Referer`` header. Example:: From 2aadc134a0a3d58cde1eb2618b3a60664adc9cf4 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:51:57 +0100 Subject: [PATCH 12/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/sphinx-guides/source/api/native-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 4cf068e948e..60bada840e9 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6176,7 +6176,7 @@ These hardened requests must also include an ``Origin`` or ``Referer`` header. Example:: - curl -b cookies.txt "$SERVER_URL/api/users/:csrf-token" + curl -b cookies.txt -H "Origin:$SERVER_URL" "$SERVER_URL/api/users/:csrf-token" Example response:: From 10846b27f4bb1143f575f0cb3eeb84dd43f31bae Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 16:59:43 +0100 Subject: [PATCH 13/16] feat: add CSRF protection for session cookie authentication endpoint --- .../edu/harvard/iq/dataverse/api/Users.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 6da5ad08fda..79d77fcc085 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -215,6 +215,7 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc } @GET + @AuthRequired @Path(":csrf-token") public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { @@ -226,15 +227,15 @@ public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { Response.Status.FORBIDDEN, "CSRF token endpoint is only available for session-cookie authentication."); } - - // Authenticate based on the current session without invoking AuthFilter, - // so that this endpoint can bootstrap the CSRF token for session-cookie auth. - User currentUser = session.getUser(); - if (!(currentUser instanceof AuthenticatedUser)) { - return error(Response.Status.UNAUTHORIZED, "User must be authenticated via session cookie."); + // AuthFilter handles authentication and Origin/Referer validation. + // The CSRF header check is skipped for this endpoint (bootstrap exception in AuthFilter) + // so the client can obtain the token before making subsequent hardened calls. + try { + getRequestAuthenticatedUserOrDie(crc); + return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken())); + } catch (WrappedResponse wr) { + return wr.getResponse(); } - - return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken())); } @POST From dd1e1c799e1d102dd14a8525c15993fe110506c5 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Wed, 18 Mar 2026 17:00:16 +0100 Subject: [PATCH 14/16] test: add unit tests for CSRF token endpoint with session cookie authentication --- .../api/UsersSessionCsrfTokenTest.java | 101 ++++++++++++++++++ .../iq/dataverse/api/auth/AuthFilterTest.java | 93 ++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java new file mode 100644 index 00000000000..0775182876a --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java @@ -0,0 +1,101 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.settings.JvmSettings; +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 org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the GET /api/users/:csrf-token endpoint in {@link Users}. + *

+ * These tests exercise the endpoint handler directly with mocked dependencies + * to verify the success path (session-cookie auth) and rejection paths + * (hardening disabled, non-session auth). + */ +@LocalJvmSettings +class UsersSessionCsrfTokenTest { + + @Test + void testGetCsrfToken_HardeningDisabled_Returns400() throws Exception { + Users sut = new Users(); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_NonSessionCookieAuth_Returns403() throws Exception { + Users sut = new Users(); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_API_KEY); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_SessionCookieAuth_ReturnsToken() throws Exception { + Users sut = new Users(); + DataverseSession session = Mockito.mock(DataverseSession.class); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(authenticatedUser); + when(session.getOrCreateApiCsrfToken()).thenReturn("test-csrf-token-value"); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + String body = response.getEntity().toString(); + assertTrue(body.contains("test-csrf-token-value")); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_SessionCookieAuthUnauthenticatedUser_ReturnsUnauthorized() throws Exception { + Users sut = new Users(); + DataverseSession session = Mockito.mock(DataverseSession.class); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // Simulate a guest/unauthenticated user set by AuthFilter + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(edu.harvard.iq.dataverse.authorization.users.GuestUser.get()); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + // getRequestAuthenticatedUserOrDie throws WrappedResponse for non-authenticated users + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } + + private void inject(Object target, Class clazz, String fieldName, Object value) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} 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 index 974c68723ee..c92e67d75e4 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -429,6 +429,99 @@ void testFilter_HardeningEnabled_StateChangingCallWithWrongCsrfTokenBlocked() th assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); } + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_CsrfBootstrapEndpointAllowedWithOriginOnly() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "users/:csrf-token"); + 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"); + // No CSRF token header — bootstrap endpoint should not require it + + 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_CsrfBootstrapEndpointWithApiPrefixAllowed() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "api/users/:csrf-token"); + 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"); + + 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_CsrfBootstrapEndpointBlockedWithoutOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "users/:csrf-token"); + 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 — should still be blocked even for bootstrap endpoint + + 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); From 8dd3b114eb3c01135e3812b18dfd7df70bcb08ed Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:07:41 +0100 Subject: [PATCH 15/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/release-notes/12178-session-cookie-api-hardening.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md index 8d2688d3fe3..7cfbb747935 100644 --- a/doc/release-notes/12178-session-cookie-api-hardening.md +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -5,7 +5,7 @@ When hardening is enabled, every API request authenticated via session cookie mu - A valid same-origin `Origin` or `Referer` header. - The `X-Dataverse-CSRF-Token` header matching the token from `GET /api/users/:csrf-token`. -This applies uniformly to all HTTP methods and all API paths, with no exceptions. Clients not on the same origin should use bearer-token authentication instead. +This applies uniformly to all HTTP methods and all API paths, except for the CSRF bootstrap endpoint (`GET /api/users/:csrf-token`), which is intentionally callable without an existing `X-Dataverse-CSRF-Token` header so clients can obtain the initial token. All subsequent session-cookie-authenticated requests must include the header. Clients not on the same origin should use bearer-token authentication instead. Additional changes: From 3333365a80352fa1e18837d8cb6ecc7074178968 Mon Sep 17 00:00:00 2001 From: Eryk Kulikowski <101262459+ErykKul@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:08:04 +0100 Subject: [PATCH 16/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/sphinx-guides/source/installation/config.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index cb4624ff468..728df2ee32e 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3952,13 +3952,13 @@ Enables additional hardening for session-cookie API usage. This flag only has an 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 requires **every** API request authenticated via session cookie to include: +When enabled, Dataverse requires all API requests authenticated via session cookie (except the CSRF bootstrap endpoint) to include: - A valid same-origin ``Origin`` or ``Referer`` header. - The ``X-Dataverse-CSRF-Token`` header matching the token obtained from ``GET /api/users/:csrf-token``. -This applies uniformly to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.) and all -API paths, with no per-endpoint exceptions. The simplicity is intentional: session-cookie API auth +The only per-endpoint exception is the CSRF bootstrap call itself (``GET /api/users/:csrf-token``), which by design cannot send the token it is obtaining. All other API paths are subject to these requirements, and this applies uniformly to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.). +The simplicity is intentional: session-cookie API auth is only used by same-origin front-end clients that always have the CSRF token available. Some ``GET`` endpoints in the codebase have side effects, so exempting reads would leave gaps.