From 0db6a7b9440b8d3eb9f78dd311a425103d44cafc Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 27 Feb 2026 09:09:07 +0100 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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. From 94f3753a09fe1b45f082bfdb5c27e58a5fd84dc3 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 28 Apr 2026 15:01:09 +0200 Subject: [PATCH 17/21] Restrict CSRF bootstrap exemption to GET --- .../java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java | 3 +++ 1 file changed, 3 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 1bd363d1f28..fd25cee56dd 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 @@ -86,6 +86,9 @@ private boolean isSessionCookieRequest(ContainerRequestContext containerRequestC * plus the authenticated session cookie, and do not require an existing CSRF header. */ private boolean isCsrfTokenBootstrapEndpoint(ContainerRequestContext containerRequestContext) { + if (!"GET".equalsIgnoreCase(containerRequestContext.getMethod())) { + return false; + } String path = containerRequestContext.getUriInfo().getPath(); if (path == null) { return false; From d210ade4aee5c1835ca704252c40f774978f3d71 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Tue, 28 Apr 2026 21:32:59 +0200 Subject: [PATCH 18/21] Extract CSRF token endpoint path into a shared constant --- .../java/edu/harvard/iq/dataverse/api/ApiConstants.java | 1 + src/main/java/edu/harvard/iq/dataverse/api/Users.java | 2 +- .../edu/harvard/iq/dataverse/api/auth/AuthFilter.java | 9 ++++----- 3 files changed, 6 insertions(+), 6 deletions(-) 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 d249d3344eb..a154099a4e1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java @@ -20,6 +20,7 @@ private ApiConstants() { 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"; + public static final String CSRF_TOKEN_ENDPOINT_PATH = ":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 79d77fcc085..22bde43230c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -216,7 +216,7 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc @GET @AuthRequired - @Path(":csrf-token") + @Path(ApiConstants.CSRF_TOKEN_ENDPOINT_PATH) public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { return error(Response.Status.BAD_REQUEST, "Session-auth hardening is disabled."); 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 fd25cee56dd..305b8319d4a 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 @@ -95,11 +95,10 @@ private boolean isCsrfTokenBootstrapEndpoint(ContainerRequestContext containerRe } 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"); + String suffix = "users/" + ApiConstants.CSRF_TOKEN_ENDPOINT_PATH; + return normalizedPath.equals(suffix) + || normalizedPath.equals("api/" + suffix) + || normalizedPath.endsWith("/" + suffix); } private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { From d2a4e5d56361d7e2665e91297c3e6ddf239d987f Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 22 May 2026 17:03:54 +0200 Subject: [PATCH 19/21] cleanup --- .../source/installation/config.rst | 2 + .../iq/dataverse/DataverseSession.java | 9 +- .../api/auth/ApiKeyAuthMechanism.java | 6 + .../iq/dataverse/api/auth/AuthFilter.java | 44 ++-- .../iq/dataverse/api/auth/AuthMechanism.java | 6 + .../api/auth/BearerTokenAuthMechanism.java | 6 + .../api/auth/CompoundAuthMechanism.java | 20 +- .../api/auth/SessionCookieAuthMechanism.java | 6 + .../api/auth/SignedUrlAuthMechanism.java | 6 + .../api/auth/WorkflowKeyAuthMechanism.java | 6 + .../settings/ConfigCheckService.java | 24 ++- .../iq/dataverse/settings/FeatureFlags.java | 1 - .../iq/dataverse/api/auth/AuthFilterTest.java | 43 ++-- .../api/auth/SessionCsrfRoundTripTest.java | 201 ++++++++++++++++++ 14 files changed, 312 insertions(+), 68 deletions(-) create mode 100644 src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 9ab624774d3..cf228108222 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -4007,6 +4007,8 @@ 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. +The CSRF token is **session-lifetime**: it is minted on first request to the bootstrap endpoint, reused for the duration of the session, and only invalidated when the session user changes (login/logout). There is no per-request rotation. Treat the token like other session-scoped secrets — do not log it, and refresh it (by logging in again) if you suspect it has leaked. + 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/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index 6d36c63a56d..ab146c5d528 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -14,6 +14,8 @@ import edu.harvard.iq.dataverse.util.SystemConfig; import java.io.IOException; import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -136,7 +138,12 @@ public synchronized String getOrCreateApiCsrfToken() { } public synchronized boolean matchesApiCsrfToken(String token) { - return token != null && apiCsrfToken != null && apiCsrfToken.equals(token); + if (token == null || apiCsrfToken == null) { + return false; + } + return MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), + apiCsrfToken.getBytes(StandardCharsets.UTF_8)); } public synchronized void clearApiCsrfToken() { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java index fbb0b484b58..86faf7ca91e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java @@ -1,6 +1,7 @@ package edu.harvard.iq.dataverse.api.auth; import edu.harvard.iq.dataverse.UserServiceBean; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; @@ -53,6 +54,11 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } + @Override + public String mechanismTag() { + return ApiConstants.AUTH_MECHANISM_API_KEY; + } + private String getRequestApiKey(ContainerRequestContext containerRequestContext) { String headerParamApiKey = containerRequestContext.getHeaderString(DATAVERSE_API_KEY_REQUEST_HEADER_NAME); String queryParamApiKey = containerRequestContext.getUriInfo().getQueryParameters().getFirst(DATAVERSE_API_KEY_REQUEST_PARAM_NAME); 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 305b8319d4a..126cac92c7e 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 @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.ApiConstants; +import edu.harvard.iq.dataverse.api.Users; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.SystemConfig; @@ -11,8 +12,11 @@ import jakarta.ws.rs.Priorities; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.ext.Provider; import java.io.IOException; +import java.lang.reflect.Method; import java.net.URI; import java.util.Locale; import java.util.logging.Logger; @@ -37,6 +41,9 @@ public class AuthFilter implements ContainerRequestFilter { @Inject private SystemConfig systemConfig; + @Context + private ResourceInfo resourceInfo; + @Override public void filter(ContainerRequestContext containerRequestContext) throws IOException { try { @@ -64,7 +71,7 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC // 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)) { + if (isCsrfTokenBootstrapEndpoint()) { return; } @@ -81,24 +88,20 @@ private boolean isSessionCookieRequest(ContainerRequestContext containerRequestC } /** - * 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. + * Returns {@code true} if JAX-RS resolved the request to {@link Users#getSessionCsrfToken}. + * For this single endpoint we rely on the authenticated session cookie plus same-origin + * checks and do not require an existing CSRF header; that's how clients bootstrap the token. + * Matching on the resolved resource method (rather than a path string) keeps the exemption + * scoped to exactly the intended endpoint. */ - private boolean isCsrfTokenBootstrapEndpoint(ContainerRequestContext containerRequestContext) { - if (!"GET".equalsIgnoreCase(containerRequestContext.getMethod())) { - return false; - } - String path = containerRequestContext.getUriInfo().getPath(); - if (path == null) { + private boolean isCsrfTokenBootstrapEndpoint() { + if (resourceInfo == null) { return false; } - String normalizedPath = path.toLowerCase(Locale.ROOT); - - String suffix = "users/" + ApiConstants.CSRF_TOKEN_ENDPOINT_PATH; - return normalizedPath.equals(suffix) - || normalizedPath.equals("api/" + suffix) - || normalizedPath.endsWith("/" + suffix); + Method method = resourceInfo.getResourceMethod(); + return method != null + && Users.class.equals(method.getDeclaringClass()) + && "getSessionCsrfToken".equals(method.getName()); } private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { @@ -129,7 +132,12 @@ private boolean isCsrfTokenValid(ContainerRequestContext containerRequestContext return requestToken != null && !requestToken.isBlank() && session.matchesApiCsrfToken(requestToken); } - private String toOrigin(String url) { + /** + * Normalizes {@code url} to its origin form ({@code scheme://host[:port]}), lowercasing scheme and host + * and omitting default ports. Returns {@code null} if the input is missing scheme or host, or is unparseable. + * Exposed for shared use by startup configuration checks. + */ + public static String toOrigin(String url) { if (url == null || url.isBlank()) { return null; } @@ -150,7 +158,7 @@ private String toOrigin(String url) { } } - private int defaultPort(String scheme) { + private static int defaultPort(String scheme) { if ("http".equals(scheme)) { return 80; } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java index bd34acbf702..b0516e09735 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java @@ -21,4 +21,10 @@ interface AuthMechanism { * @throws edu.harvard.iq.dataverse.api.auth.WrappedAuthErrorResponse if there is a credential provided, but invalid. */ User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse; + + /** + * Returns a short tag identifying this mechanism, used by downstream filters (e.g. {@code AuthFilter}) + * to apply mechanism-specific policies. Values are defined in {@link edu.harvard.iq.dataverse.api.ApiConstants}. + */ + String mechanismTag(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index 3ee9bb909f2..c11668ea601 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -1,6 +1,7 @@ package edu.harvard.iq.dataverse.api.auth; import edu.harvard.iq.dataverse.UserServiceBean; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -53,6 +54,11 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) return userSvc.updateLastApiUseTime(authUser); } + @Override + public String mechanismTag() { + return ApiConstants.AUTH_MECHANISM_BEARER_TOKEN; + } + /** * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 * 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 4c9a90d4327..a0f01082026 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 @@ -42,7 +42,7 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) User userFromRequest = authMechanism.findUserFromRequest(containerRequestContext); if (userFromRequest != null) { user = userFromRequest; - containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, getAuthMechanismTag(authMechanism)); + containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, authMechanism.mechanismTag()); break; } } @@ -52,22 +52,8 @@ 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; - } + @Override + public String mechanismTag() { 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 3a1d93041bd..d36b036bc36 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 @@ -1,6 +1,7 @@ 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; @@ -19,4 +20,9 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } return null; } + + @Override + public String mechanismTag() { + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java index d21d91a07c2..9d12ebfb96e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.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.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.*; import edu.harvard.iq.dataverse.privateurl.PrivateUrl; @@ -46,6 +47,11 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); } + @Override + public String mechanismTag() { + return ApiConstants.AUTH_MECHANISM_SIGNED_URL; + } + private String getSignedUrlRequestParameter(ContainerRequestContext containerRequestContext) { return containerRequestContext.getUriInfo().getQueryParameters().getFirst(SIGNED_URL_TOKEN); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java index df54b69af96..b65c69bc202 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.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.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -33,6 +34,11 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); } + @Override + public String mechanismTag() { + return ApiConstants.AUTH_MECHANISM_WORKFLOW_KEY; + } + private String getRequestWorkflowKey(ContainerRequestContext containerRequestContext) { String headerParamWorkflowKey = containerRequestContext.getHeaderString(DATAVERSE_WORKFLOW_KEY_REQUEST_HEADER_NAME); String queryParamWorkflowKey = containerRequestContext.getUriInfo().getQueryParameters().getFirst(DATAVERSE_WORKFLOW_KEY_REQUEST_PARAM_NAME); diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java index 96222f40daf..462f8964b5f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java @@ -1,11 +1,13 @@ package edu.harvard.iq.dataverse.settings; import edu.harvard.iq.dataverse.MailServiceBean; +import edu.harvard.iq.dataverse.api.auth.AuthFilter; import edu.harvard.iq.dataverse.pidproviders.PidProviderFactoryBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; import edu.harvard.iq.dataverse.util.FileUtil; import edu.harvard.iq.dataverse.util.MailSessionProducer; +import edu.harvard.iq.dataverse.util.SystemConfig; import jakarta.annotation.PostConstruct; import jakarta.ejb.DependsOn; import jakarta.ejb.Singleton; @@ -47,9 +49,29 @@ public void startup() { if (!checkSystemDirectories() || !checkPidProviders()) { throw new ConfigurationError("Not all configuration checks passed successfully. See logs above."); } - + // Only checks resulting in warnings, nothing critical that needs to stop deployment checkSystemMailSetup(); + checkSessionAuthHardening(); + } + + /** + * If session-cookie API auth hardening is enabled, the site URL must be parseable so that + * {@code AuthFilter} can perform Origin/Referer validation. A missing or malformed value + * causes every hardened request to be rejected at runtime; log a SEVERE message at startup + * so operators don't chase silent 403s. + */ + void checkSessionAuthHardening() { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return; + } + String siteUrl = SystemConfig.getDataverseSiteUrlStatic(); + if (AuthFilter.toOrigin(siteUrl) == null) { + logger.log(Level.SEVERE, () -> "Feature flag " + FeatureFlags.API_SESSION_AUTH_HARDENING.name() + + " is enabled, but the configured dataverse site URL (" + siteUrl + + ") is missing or unparseable. All session-cookie API requests will be rejected with 403" + + " until this is fixed."); + } } /** 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 0e6f157ea69..4ba7f4cb15a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -43,7 +43,6 @@ 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.10 */ API_SESSION_AUTH_HARDENING("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 c92e67d75e4..2bca0a2ae32 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 @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.ApiConstants; +import edu.harvard.iq.dataverse.api.Users; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; @@ -9,6 +10,7 @@ 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.container.ResourceInfo; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import org.junit.jupiter.api.Test; @@ -16,6 +18,7 @@ import org.mockito.Mockito; import java.lang.reflect.Field; +import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -454,36 +457,7 @@ void testFilter_HardeningEnabled_CsrfBootstrapEndpointAllowedWithOriginOnly() th 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); + inject(sut, "resourceInfo", mockBootstrapResourceInfo()); sut.filter(requestContext); @@ -514,6 +488,7 @@ void testFilter_HardeningEnabled_CsrfBootstrapEndpointBlockedWithoutOrigin() thr inject(sut, "compoundAuthMechanism", compound); inject(sut, "session", session); inject(sut, "systemConfig", systemConfig); + inject(sut, "resourceInfo", mockBootstrapResourceInfo()); sut.filter(requestContext); @@ -531,6 +506,14 @@ private ContainerRequestContext mockRequestContext(String method, String path) { return requestContext; } + private ResourceInfo mockBootstrapResourceInfo() throws NoSuchMethodException { + ResourceInfo resourceInfo = Mockito.mock(ResourceInfo.class); + Method method = Users.class.getMethod("getSessionCsrfToken", ContainerRequestContext.class); + Mockito.>when(resourceInfo.getResourceClass()).thenReturn(Users.class); + when(resourceInfo.getResourceMethod()).thenReturn(method); + return resourceInfo; + } + private void inject(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java new file mode 100644 index 00000000000..5bce822dc41 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java @@ -0,0 +1,201 @@ +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.api.Users; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +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.container.ResourceInfo; +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 java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Wider unit tests that exercise the {@link AuthFilter} + {@link Users#getSessionCsrfToken} + * composition with a real {@link DataverseSession} (not mocked). These tests verify the + * actual CSRF token round-trip: the endpoint mints a token, the filter accepts the same + * token on follow-up requests, and rejects mismatches. + * + *

Existing tests mock the session, so they don't catch issues where the endpoint and + * filter diverge on token storage or comparison. + */ +@LocalJvmSettings +class SessionCsrfRoundTripTest { + + private static final String SITE_URL = "https://demo.dataverse.org"; + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testRoundTrip_BootstrapThenMutatingCall_Allowed() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + + // Step 1: bootstrap GET /api/users/:csrf-token through the filter. + ContainerRequestContext bootstrapCtx = mockSessionCookieRequest("GET", "users/:csrf-token"); + when(bootstrapCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, mockBootstrapResourceInfo()); + + filter.filter(bootstrapCtx); + verify(bootstrapCtx, never()).abortWith(any(Response.class)); + + // Step 2: call the endpoint method directly and verify it issued an OK response. + Users users = newUsersWithSession(session); + when(bootstrapCtx.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)).thenReturn(user); + Response bootstrapResponse = users.getSessionCsrfToken(bootstrapCtx); + assertEquals(Response.Status.OK.getStatusCode(), bootstrapResponse.getStatus()); + + // The endpoint stored the token on the real session — pull it back out to drive the next request. + String token = session.getOrCreateApiCsrfToken(); + assertNotNull(token); + + // Step 3: subsequent mutating call with that token should pass through the filter. + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn(token); + AuthFilter mutatingFilter = new AuthFilter(); + injectAuthFilterDeps(mutatingFilter, mockCompoundAuth(user), session, systemConfig, null); + + mutatingFilter.filter(mutatingCtx); + verify(mutatingCtx, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testRoundTrip_MutatingCallWithWrongToken_Blocked() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + // Mint a token on the real session. + String validToken = session.getOrCreateApiCsrfToken(); + assertNotNull(validToken); + + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)) + .thenReturn(validToken + "-not-quite"); + + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, null); + + filter.filter(mutatingCtx); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mutatingCtx).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 testRoundTrip_MutatingCallWithoutBootstrap_Blocked() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + + // No bootstrap: don't call getOrCreateApiCsrfToken. Client supplies a fabricated value. + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)) + .thenReturn("00000000-0000-0000-0000-000000000000"); + + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, null); + + filter.filter(mutatingCtx); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mutatingCtx).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), + abortResponseCaptor.getValue().getStatus()); + } + + private DataverseSession newSessionWithAuthenticatedUser() { + DataverseSession session = new DataverseSession(); + AuthenticatedUser user = new AuthenticatedUser(); + // setUser does FacesContext-dependent work; we bypass it by writing the user field directly + // (the session-cookie auth chain only reads getUser()). + try { + Field userField = DataverseSession.class.getDeclaredField("user"); + userField.setAccessible(true); + userField.set(session, user); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + return session; + } + + private SystemConfig mockSystemConfig() { + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + when(systemConfig.getDataverseSiteUrl()).thenReturn(SITE_URL); + return systemConfig; + } + + private CompoundAuthMechanism mockCompoundAuth(AuthenticatedUser user) throws WrappedAuthErrorResponse { + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + when(compound.findUserFromRequest(any(ContainerRequestContext.class))).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return user; + }); + return compound; + } + + private ContainerRequestContext mockSessionCookieRequest(String method, String path) { + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + UriInfo uriInfo = Mockito.mock(UriInfo.class); + when(crc.getMethod()).thenReturn(method); + when(crc.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPath()).thenReturn(path); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return crc; + } + + private ResourceInfo mockBootstrapResourceInfo() throws NoSuchMethodException { + ResourceInfo resourceInfo = Mockito.mock(ResourceInfo.class); + Method method = Users.class.getMethod("getSessionCsrfToken", ContainerRequestContext.class); + when(resourceInfo.getResourceMethod()).thenReturn(method); + return resourceInfo; + } + + private void injectAuthFilterDeps(AuthFilter filter, CompoundAuthMechanism compound, + DataverseSession session, SystemConfig systemConfig, + ResourceInfo resourceInfo) throws Exception { + injectField(filter, "compoundAuthMechanism", compound); + injectField(filter, "session", session); + injectField(filter, "systemConfig", systemConfig); + injectField(filter, "resourceInfo", resourceInfo); + } + + private Users newUsersWithSession(DataverseSession session) throws Exception { + Users users = new Users(); + injectField(users, "session", session); + return users; + } + + private void injectField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} From fa280a99898c18daa83eb0c76912faf11d784eab Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 22 May 2026 17:48:24 +0200 Subject: [PATCH 20/21] Session cookie auth with guest user restored; potentially could have bad side effects --- .../iq/dataverse/api/auth/AuthFilter.java | 9 ++++-- .../api/auth/SessionCookieAuthMechanism.java | 3 +- .../iq/dataverse/api/auth/AuthFilterTest.java | 32 +++++++++++++++++++ .../auth/SessionCookieAuthMechanismTest.java | 4 +-- 4 files changed, 42 insertions(+), 6 deletions(-) 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 126cac92c7e..fd0624a07d5 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 @@ -49,13 +49,13 @@ public void filter(ContainerRequestContext containerRequestContext) throws IOExc try { User user = compoundAuthMechanism.findUserFromRequest(containerRequestContext); containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); - applySessionAuthHardening(containerRequestContext); + applySessionAuthHardening(containerRequestContext, user); } catch (WrappedAuthErrorResponse e) { containerRequestContext.abortWith(e.getResponse()); } } - private void applySessionAuthHardening(ContainerRequestContext containerRequestContext) + private void applySessionAuthHardening(ContainerRequestContext containerRequestContext, User user) throws WrappedAuthErrorResponse { if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { return; @@ -63,6 +63,11 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC if (!isSessionCookieRequest(containerRequestContext)) { return; } + // CSRF/origin protections guard authenticated session state; guests carry no privileges + // worth forging, so the hardening checks would only add friction without security benefit. + if (user == null || !user.isAuthenticated()) { + return; + } if (!isOriginOrRefererAllowed(containerRequestContext)) { throw new WrappedForbiddenAuthErrorResponse( 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 d36b036bc36..ee0832c1a5f 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 @@ -15,8 +15,7 @@ public class SessionCookieAuthMechanism implements AuthMechanism { @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { if (FeatureFlags.API_SESSION_AUTH.enabled()) { - User user = session.getUser(); - return user != null && user.isAuthenticated() ? user : null; + return session.getUser(); } return null; } 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 2bca0a2ae32..f1c0fd9751e 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 @@ -4,6 +4,7 @@ import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.api.Users; 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.SystemConfig; @@ -307,6 +308,37 @@ void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Ex verify(requestContext, never()).abortWith(any(Response.class)); } + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_GuestSessionCookieRequestSkipsHardening() 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(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return GuestUser.get(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // No Origin/Referer and no CSRF token — would block an authenticated session, but a guest + // has no privileges to forge, so hardening must short-circuit before these checks. + + 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, GuestUser.get()); + } + @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAnyPath() throws Exception { 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 4be29c32387..a87e8edcb4b 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 @@ -50,13 +50,13 @@ void testFindUserFromRequest_FeatureFlagEnabled_UserAuthenticated() throws Wrapp @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth") - void testFindUserFromRequest_FeatureFlagEnabled_GuestSessionUserReturnsNull() throws WrappedAuthErrorResponse { + void testFindUserFromRequest_FeatureFlagEnabled_GuestSessionUserReturnedAsIs() 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); + assertEquals(GuestUser.get(), actual); } } From 0781e6b3ba7f515b7ad1954c3efd02fa9bb675c6 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Fri, 3 Jul 2026 20:19:22 +0200 Subject: [PATCH 21/21] =?UTF-8?q?12178:=20address=20CSRF=20hardening=20rev?= =?UTF-8?q?iew=20=E2=80=94=20coverage,=20header=20handling,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../12178-session-cookie-api-hardening.md | 8 +- doc/sphinx-guides/source/api/native-api.rst | 11 +- .../source/installation/config.rst | 12 +- .../iq/dataverse/DataverseSession.java | 15 +- .../iq/dataverse/api/ApiConstants.java | 11 +- .../edu/harvard/iq/dataverse/api/Logout.java | 13 + .../edu/harvard/iq/dataverse/api/Users.java | 24 +- .../api/auth/ApiKeyAuthMechanism.java | 6 - .../iq/dataverse/api/auth/AuthFilter.java | 93 ++- .../iq/dataverse/api/auth/AuthMechanism.java | 6 - .../api/auth/BearerTokenAuthMechanism.java | 6 - .../api/auth/CompoundAuthMechanism.java | 23 +- .../api/auth/SessionCookieAuthMechanism.java | 6 - .../api/auth/SignedUrlAuthMechanism.java | 6 - .../api/auth/WorkflowKeyAuthMechanism.java | 6 - .../settings/ConfigCheckService.java | 31 +- .../iq/dataverse/settings/FeatureFlags.java | 15 +- .../iq/dataverse/util/UrlOriginUtil.java | 59 ++ .../iq/dataverse/DataverseSessionTest.java | 22 + .../api/UsersSessionCsrfTokenTest.java | 30 +- .../iq/dataverse/api/auth/AuthFilterTest.java | 623 ++++++------------ .../api/auth/CompoundAuthMechanismTest.java | 9 +- 22 files changed, 464 insertions(+), 571 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md index 7cfbb747935..c1fa1b34c15 100644 --- a/doc/release-notes/12178-session-cookie-api-hardening.md +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -1,11 +1,13 @@ 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, every API request authenticated via session cookie must include: +When hardening is enabled, every API request authenticated via session cookie (from a fully-authenticated session) must satisfy: -- A valid same-origin `Origin` or `Referer` header. +- Any `Origin` or `Referer` header it carries must match the site origin. Requests carrying neither header — such as same-origin `GET`s under `Referrer-Policy: no-referrer` — are not rejected on that basis; the CSRF token below is then the deciding credential. (Cross-site `fetch`/XHR/form submissions always carry `Origin`, and a cross-site page can neither read nor set the CSRF 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, 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. +This applies to all HTTP methods on all `@AuthRequired` API endpoints — the set the authentication filter runs on. That includes endpoints that also read the JSF session directly (e.g. the multipart direct-upload helpers `GET {id}/uploadurls` and `PUT`/`DELETE /api/datasets/mpupload`) and `POST /api/logout` (annotated as part of this change). Session-cookie clients must send the `X-Dataverse-CSRF-Token` header on those calls too — including logout and the direct-upload flow. The one per-endpoint exception is the CSRF bootstrap call itself (`GET /api/users/:csrf-token`), which is intentionally callable without an existing `X-Dataverse-CSRF-Token` header so clients can obtain the initial token; its response is marked `Cache-Control: no-store`. Guest and `PrivateUrlUser` (anonymized preview link) sessions are exempt — a guest has nothing to forge and a preview session is read-only with no cross-origin-readable response, so anonymous preview-link downloads keep working under hardening without a token. Clients not on the same origin should use bearer-token authentication instead. + +Startup diagnostics: enabling the hardening flag without `dataverse.feature.api-session-auth` logs a warning (the hardening would otherwise be silently inert), and a missing/unparseable site URL logs a SEVERE message since origin validation depends on it. Additional changes: diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 93738ba64bb..554c4a674cc 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6274,10 +6274,15 @@ 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 all +This endpoint requires a fully-authenticated session-cookie request and returns +a 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. +(Guest and private-URL preview sessions are exempt from the hardening and are +not issued a token.) Any ``Origin`` +or ``Referer`` header those requests carry must match the site origin; +requests carrying neither header are accepted on the strength of the token. +The response is marked ``Cache-Control: no-store`` — treat the token like a +session secret. Example:: diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index cf228108222..7276b553971 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -3997,17 +3997,19 @@ 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 all API requests authenticated via session cookie (except the CSRF bootstrap endpoint) to include: +When enabled, Dataverse requires API requests authenticated via session cookie (from a fully-authenticated session) to satisfy: -- A valid same-origin ``Origin`` or ``Referer`` header. +- Any ``Origin`` or ``Referer`` header the request carries must match the site origin. Requests carrying neither header — for example same-origin ``GET`` requests from a page served with ``Referrer-Policy: no-referrer`` — are not rejected on that basis; the CSRF token below then decides. This keeps the hardening usable on installations that suppress referrer information, without weakening the protection: cross-site ``fetch``/XHR/form submissions always carry ``Origin``, and a cross-site page can neither read nor set the CSRF header. - The ``X-Dataverse-CSRF-Token`` header matching the token obtained from ``GET /api/users/:csrf-token``. -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 +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 (its response carries ``Cache-Control: no-store``). These requirements apply to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.) on all ``@AuthRequired`` API endpoints — the set the authentication filter runs on. That is every endpoint whose request user is resolved by the filter, including ones that also read the JSF session directly (such as the multipart direct-upload helpers ``GET {id}/uploadurls``, ``PUT``/``DELETE /api/datasets/mpupload``) and ``POST /api/logout``. A session-cookie client must therefore send ``X-Dataverse-CSRF-Token`` on those calls too — including logout and the direct-upload flow — once hardening is enabled. +The per-method 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. -The CSRF token is **session-lifetime**: it is minted on first request to the bootstrap endpoint, reused for the duration of the session, and only invalidated when the session user changes (login/logout). There is no per-request rotation. Treat the token like other session-scoped secrets — do not log it, and refresh it (by logging in again) if you suspect it has leaked. +Guest sessions and private-URL preview sessions (``PrivateUrlUser``) are exempt: a guest holds no privileges worth forging, and a private-URL preview is read-only (no state to forge, and its responses are not readable cross-origin), so the checks would add no protection. This also keeps anonymous preview-link file downloads — which are served through the JSF preview flow that has no way to obtain a token — working with hardening enabled. + +The CSRF token is **session-lifetime**: it is minted on first request to the bootstrap endpoint, reused for the duration of the session, and invalidated whenever the session identity changes (login, logout, or the account being deleted/deactivated). There is no per-request rotation. Treat the token like other session-scoped secrets — do not log it, and refresh it (by logging in again) if you suspect it has leaked. Clients not on the same origin should use bearer-token authentication instead. diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index ab146c5d528..c3e6bfdce25 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -115,17 +115,28 @@ public User getUser(boolean lookupAuthenticatedUserAgain) { AuthenticatedUser auFreshLookup = authenticationService.findByID(auFromSession.getId()); if (auFreshLookup == null) { logger.fine("getUser found user no longer exists (was deleted). Returning GuestUser."); - user = GuestUser.get(); + demoteToGuest(); } else { if (auFreshLookup.isDeactivated()) { logger.fine("getUser found user is deactivated. Returning GuestUser."); - user = GuestUser.get(); + demoteToGuest(); } } } return user; } + /** + * Demotes the session to guest after discovering the authenticated user was + * deleted or deactivated. The CSRF token is dropped along with the identity + * it was minted for — {@code setUser} does the same on login/logout, and a + * token must never outlive its user. + */ + private void demoteToGuest() { + user = GuestUser.get(); + clearApiCsrfToken(); + } + /** * Returns a CSRF token scoped to the current Dataverse session. * The token is lazily created and reused until it is explicitly cleared. 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 a154099a4e1..a08386afeea 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java @@ -12,15 +12,16 @@ private ApiConstants() { // Authentication public static final String CONTAINER_REQUEST_CONTEXT_USER = "user"; + // Set on the request by CompoundAuthMechanism only for session-cookie + // authentication — the one mechanism downstream code (AuthFilter's CSRF + // hardening, the token bootstrap endpoint) needs to distinguish. Absent + // for every other mechanism. 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"; public static final String CSRF_TOKEN_ENDPOINT_PATH = ":csrf-token"; + public static final String ORIGIN_HEADER = "Origin"; + public static final String REFERER_HEADER = "Referer"; // Dataset public static final String DS_VERSION_LATEST = ":latest"; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Logout.java b/src/main/java/edu/harvard/iq/dataverse/api/Logout.java index e8d8be04459..a6518e293dd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Logout.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Logout.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.DataverseHeaderFragment; import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.settings.FeatureFlags; import jakarta.inject.Inject; @@ -23,9 +24,21 @@ public class Logout extends AbstractApiBean { * This endpoint replicates the logic from the JSF Log Out feature: * @see DataverseHeaderFragment#logout() *

+ * {@code @AuthRequired} routes this endpoint through {@code AuthFilter} so the + * session-cookie CSRF hardening (when enabled) covers it: logout is state-changing + * and session-cookie-authenticated, and without the annotation it would be the one + * such endpoint the hardening never sees. Two behavior changes follow from running + * the filter, both intended: with hardening enabled a session-cookie logout must + * now carry the {@code X-Dataverse-CSRF-Token} header like any other state-changing + * call, and an invalid non-session credential (e.g. a bad API key) is now + * rejected by the filter with 401 rather than reaching the handler. A valid + * non-session credential still falls through to the handler's "no session cookie" + * response as before, since the handler reads the JSF session directly. + *

* TODO: This endpoint must change when a final API authentication mechanism is established for use cases / applications subject to Log Out */ @POST + @AuthRequired @Path("/") public Response logout() { if (!FeatureFlags.API_SESSION_AUTH.enabled()) { 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 22bde43230c..7d88779c800 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -8,6 +8,7 @@ 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.api.auth.CompoundAuthMechanism; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; @@ -221,8 +222,7 @@ 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)) { + if (!CompoundAuthMechanism.isSessionCookieRequest(crc)) { return error( Response.Status.FORBIDDEN, "CSRF token endpoint is only available for session-cookie authentication."); @@ -230,12 +230,22 @@ public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { // 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(); + // + // Only fully-authenticated session users get a token — mirroring the hardening + // scope in AuthFilter, which exempts guests and PrivateUrlUser preview sessions + // (both isAuthenticated() == false). Those exempt sessions are never asked for a + // token, so issuing one would be pointless. + User user = getRequestUser(crc); + if (user == null || !user.isAuthenticated()) { + return error(Response.Status.UNAUTHORIZED, "An authenticated session is required to obtain a CSRF token."); } + // no-store: the body carries a session secret; neither browser caches nor + // intermediaries should retain it (bfcache/disk-cache copies would outlive + // the page that fetched it). + return Response.fromResponse( + ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken()))) + .header("Cache-Control", "no-store") + .build(); } @POST diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java index 86faf7ca91e..fbb0b484b58 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/ApiKeyAuthMechanism.java @@ -1,7 +1,6 @@ package edu.harvard.iq.dataverse.api.auth; import edu.harvard.iq.dataverse.UserServiceBean; -import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; @@ -54,11 +53,6 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_API_KEY); } - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_API_KEY; - } - private String getRequestApiKey(ContainerRequestContext containerRequestContext) { String headerParamApiKey = containerRequestContext.getHeaderString(DATAVERSE_API_KEY_REQUEST_HEADER_NAME); String queryParamApiKey = containerRequestContext.getUriInfo().getQueryParameters().getFirst(DATAVERSE_API_KEY_REQUEST_PARAM_NAME); 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 fd0624a07d5..6014970bda3 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 @@ -6,6 +6,7 @@ import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.FeatureFlags; import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.UrlOriginUtil; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -17,13 +18,17 @@ import jakarta.ws.rs.ext.Provider; import java.io.IOException; import java.lang.reflect.Method; -import java.net.URI; -import java.util.Locale; import java.util.logging.Logger; /** * @author Guillermo Portas * Dedicated filter to authenticate the user requesting an API endpoint that requires user authentication. + * + *

Note on scope: this filter is name-bound to {@link AuthRequired}, so the + * session-cookie CSRF hardening below protects exactly the {@code @AuthRequired} + * endpoints. Endpoints that read the session directly without the annotation are + * NOT covered — keep that set empty for state-changing endpoints, or annotate + * them (as {@code /api/logout} is). */ @AuthRequired @Provider @@ -60,15 +65,28 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { return; } - if (!isSessionCookieRequest(containerRequestContext)) { + if (!CompoundAuthMechanism.isSessionCookieRequest(containerRequestContext)) { return; } - // CSRF/origin protections guard authenticated session state; guests carry no privileges - // worth forging, so the hardening checks would only add friction without security benefit. + // Only fully-authenticated session users are hardened. isAuthenticated() is false + // for both GuestUser and PrivateUrlUser, and both are deliberately exempt: + // - a guest carries no privileges worth forging; + // - a private-URL preview session is read-only (no state to forge) and its + // responses are not cross-origin readable, so CSRF hardening adds no + // protection — while the JSF preview flow that mints these sessions has no + // way to obtain or send a token, so hardening it would only break anonymous + // preview downloads. if (user == null || !user.isAuthenticated()) { return; } + // Browser-sent Origin/Referer headers must match the site origin when present. + // When BOTH are absent we fall through to the CSRF token instead of failing + // closed: browsers omit Origin on same-origin GETs and suppress Referer under + // Referrer-Policy: no-referrer, so a legitimate same-origin request can carry + // neither — while the cross-site deliveries that matter cannot reach the token + // check anyway (cross-site fetch/XHR/form POSTs always carry Origin, and a + // cross-site attacker cannot read or set the X-Dataverse-CSRF-Token header). if (!isOriginOrRefererAllowed(containerRequestContext)) { throw new WrappedForbiddenAuthErrorResponse( "Request origin validation failed for session-cookie authentication."); @@ -86,12 +104,6 @@ private void applySessionAuthHardening(ContainerRequestContext containerRequestC } } - private boolean isSessionCookieRequest(ContainerRequestContext containerRequestContext) { - Object authMechanism = containerRequestContext - .getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM); - return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism); - } - /** * Returns {@code true} if JAX-RS resolved the request to {@link Users#getSessionCsrfToken}. * For this single endpoint we rely on the authenticated session cookie plus same-origin @@ -109,24 +121,29 @@ private boolean isCsrfTokenBootstrapEndpoint() { && "getSessionCsrfToken".equals(method.getName()); } + /** + * Validates any Origin/Referer headers that are present against the site origin. + * Absence of both headers is allowed — the CSRF token check that follows is then + * the deciding credential (see the caller for the reasoning). + */ 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"); + String originHeader = containerRequestContext.getHeaderString(ApiConstants.ORIGIN_HEADER); + String refererHeader = containerRequestContext.getHeaderString(ApiConstants.REFERER_HEADER); boolean hasOrigin = originHeader != null && !originHeader.isBlank(); boolean hasReferer = refererHeader != null && !refererHeader.isBlank(); if (!hasOrigin && !hasReferer) { + return true; + } + String allowedOrigin = UrlOriginUtil.toOrigin(systemConfig.getDataverseSiteUrl()); + if (allowedOrigin == null) { + logger.warning("Unable to validate Origin/Referer for session hardening: dataverse site URL is invalid."); return false; } - if (hasOrigin && !allowedOrigin.equals(toOrigin(originHeader))) { + if (hasOrigin && !allowedOrigin.equals(UrlOriginUtil.toOrigin(originHeader))) { return false; } - if (hasReferer && !allowedOrigin.equals(toOrigin(refererHeader))) { + if (hasReferer && !allowedOrigin.equals(UrlOriginUtil.toOrigin(refererHeader))) { return false; } return true; @@ -136,40 +153,4 @@ private boolean isCsrfTokenValid(ContainerRequestContext containerRequestContext String requestToken = containerRequestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER); return requestToken != null && !requestToken.isBlank() && session.matchesApiCsrfToken(requestToken); } - - /** - * Normalizes {@code url} to its origin form ({@code scheme://host[:port]}), lowercasing scheme and host - * and omitting default ports. Returns {@code null} if the input is missing scheme or host, or is unparseable. - * Exposed for shared use by startup configuration checks. - */ - public static 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 static 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/AuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java index b0516e09735..bd34acbf702 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthMechanism.java @@ -21,10 +21,4 @@ interface AuthMechanism { * @throws edu.harvard.iq.dataverse.api.auth.WrappedAuthErrorResponse if there is a credential provided, but invalid. */ User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse; - - /** - * Returns a short tag identifying this mechanism, used by downstream filters (e.g. {@code AuthFilter}) - * to apply mechanism-specific policies. Values are defined in {@link edu.harvard.iq.dataverse.api.ApiConstants}. - */ - String mechanismTag(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java index c11668ea601..3ee9bb909f2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/BearerTokenAuthMechanism.java @@ -1,7 +1,6 @@ package edu.harvard.iq.dataverse.api.auth; import edu.harvard.iq.dataverse.UserServiceBean; -import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -54,11 +53,6 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) return userSvc.updateLastApiUseTime(authUser); } - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_BEARER_TOKEN; - } - /** * Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750 * 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 a0f01082026..10a767c8779 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 @@ -36,13 +36,20 @@ 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, authMechanism.mechanismTag()); + // Session-cookie is the only mechanism downstream code needs to + // recognize (AuthFilter's CSRF hardening, the token bootstrap + // endpoint), so it is the only one tagged — the property is + // simply absent for every other mechanism. + if (authMechanism instanceof SessionCookieAuthMechanism) { + containerRequestContext.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } break; } } @@ -52,8 +59,14 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) return user; } - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_NONE; + /** + * Whether this request was authenticated via the session cookie, per the + * tag {@link #findUserFromRequest} set. Lives next to the code that writes + * the tag so readers ({@code AuthFilter}, the CSRF token endpoint) share + * one definition. + */ + public static boolean isSessionCookieRequest(ContainerRequestContext containerRequestContext) { + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals( + containerRequestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)); } } 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 ee0832c1a5f..c1471c3f5b3 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 @@ -1,7 +1,6 @@ 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; @@ -19,9 +18,4 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } return null; } - - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE; - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java index 9d12ebfb96e..d21d91a07c2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SignedUrlAuthMechanism.java @@ -1,6 +1,5 @@ package edu.harvard.iq.dataverse.api.auth; -import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.*; import edu.harvard.iq.dataverse.privateurl.PrivateUrl; @@ -47,11 +46,6 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL); } - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_SIGNED_URL; - } - private String getSignedUrlRequestParameter(ContainerRequestContext containerRequestContext) { return containerRequestContext.getUriInfo().getQueryParameters().getFirst(SIGNED_URL_TOKEN); } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java index b65c69bc202..df54b69af96 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/WorkflowKeyAuthMechanism.java @@ -1,6 +1,5 @@ package edu.harvard.iq.dataverse.api.auth; -import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -34,11 +33,6 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) throw new WrappedUnauthorizedAuthErrorResponse(RESPONSE_MESSAGE_BAD_WORKFLOW_KEY); } - @Override - public String mechanismTag() { - return ApiConstants.AUTH_MECHANISM_WORKFLOW_KEY; - } - private String getRequestWorkflowKey(ContainerRequestContext containerRequestContext) { String headerParamWorkflowKey = containerRequestContext.getHeaderString(DATAVERSE_WORKFLOW_KEY_REQUEST_HEADER_NAME); String queryParamWorkflowKey = containerRequestContext.getUriInfo().getQueryParameters().getFirst(DATAVERSE_WORKFLOW_KEY_REQUEST_PARAM_NAME); diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java index 462f8964b5f..097848e332d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java @@ -1,13 +1,13 @@ package edu.harvard.iq.dataverse.settings; import edu.harvard.iq.dataverse.MailServiceBean; -import edu.harvard.iq.dataverse.api.auth.AuthFilter; import edu.harvard.iq.dataverse.pidproviders.PidProviderFactoryBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; import edu.harvard.iq.dataverse.util.FileUtil; import edu.harvard.iq.dataverse.util.MailSessionProducer; import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.UrlOriginUtil; import jakarta.annotation.PostConstruct; import jakarta.ejb.DependsOn; import jakarta.ejb.Singleton; @@ -56,21 +56,36 @@ public void startup() { } /** - * If session-cookie API auth hardening is enabled, the site URL must be parseable so that - * {@code AuthFilter} can perform Origin/Referer validation. A missing or malformed value - * causes every hardened request to be rejected at runtime; log a SEVERE message at startup - * so operators don't chase silent 403s. + * Startup diagnostics for session-cookie API auth hardening. Two silent + * misconfigurations are called out: + *

    + *
  • Hardening enabled without {@code api-session-auth}: requests are never + * tagged as session-cookie-authenticated, so the hardening checks never + * run — the operator believes CSRF protections are active while getting + * none. (The reverse combination — session auth without hardening — is + * warned about in the feature-flag documentation.)
  • + *
  • Hardening enabled with a missing/unparseable site URL: {@code AuthFilter} + * cannot establish the allowed origin, so every session-cookie request that + * carries an {@code Origin} or {@code Referer} header is rejected with 403 + * (header-less requests are still decided by the CSRF token).
  • + *
*/ void checkSessionAuthHardening() { if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { return; } + if (!FeatureFlags.API_SESSION_AUTH.enabled()) { + logger.log(Level.WARNING, () -> "Feature flag " + FeatureFlags.API_SESSION_AUTH_HARDENING.name() + + " is enabled, but " + FeatureFlags.API_SESSION_AUTH.name() + + " is not. Session-cookie API authentication is off, so the hardening checks never run" + + " and the flag has no effect. Enable dataverse.feature.api-session-auth as well."); + } String siteUrl = SystemConfig.getDataverseSiteUrlStatic(); - if (AuthFilter.toOrigin(siteUrl) == null) { + if (UrlOriginUtil.toOrigin(siteUrl) == null) { logger.log(Level.SEVERE, () -> "Feature flag " + FeatureFlags.API_SESSION_AUTH_HARDENING.name() + " is enabled, but the configured dataverse site URL (" + siteUrl - + ") is missing or unparseable. All session-cookie API requests will be rejected with 403" - + " until this is fixed."); + + ") is missing or unparseable. Session-cookie API requests carrying an Origin or Referer" + + " header will be rejected with 403 until this is fixed."); } } 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 4ba7f4cb15a..ed3748e5b18 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -37,12 +37,19 @@ public enum FeatureFlags { */ API_SESSION_AUTH("api-session-auth"), /** - * Enables additional hardening for session-cookie API authentication. - * 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. + * Enables additional hardening for session-cookie API authentication on + * {@code @AuthRequired} endpoints. When enabled, a session-cookie request + * from a fully-authenticated session must carry the {@code X-Dataverse-CSRF-Token} + * header (obtained from {@code GET /api/users/:csrf-token}), and any + * {@code Origin}/{@code Referer} headers it carries must match the site + * origin. Requests carrying neither header — e.g. same-origin GETs under + * {@code Referrer-Policy: no-referrer} — are decided by the CSRF token + * alone. + * This feature only works when the feature flag {@link #API_SESSION_AUTH} + * is also enabled; a startup warning is logged otherwise. * * @apiNote Raise flag by setting "dataverse.feature.api-session-auth-hardening" + * @since Dataverse 6.11 */ API_SESSION_AUTH_HARDENING("api-session-auth-hardening"), /** diff --git a/src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java b/src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java new file mode 100644 index 00000000000..a4b529e1fd2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java @@ -0,0 +1,59 @@ +package edu.harvard.iq.dataverse.util; + +import java.net.MalformedURLException; +import java.net.URI; +import java.util.Locale; + +/** + * Normalizes URLs to their web-origin form for same-origin comparisons. + * Lives in the util layer because it is shared by request-time checks + * ({@code AuthFilter}'s session-cookie CSRF hardening) and startup + * configuration validation ({@code ConfigCheckService}) — neither of which + * should depend on the other. + */ +public final class UrlOriginUtil { + + private UrlOriginUtil() { + } + + /** + * Normalizes {@code url} to its origin form ({@code scheme://host[:port]}), + * lowercasing scheme and host and omitting the scheme's default port. + * Returns {@code null} if the input is missing scheme or host, or is + * unparseable. + */ + public static 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(uri)) { + return scheme + "://" + host; + } + return scheme + "://" + host + ":" + port; + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * The scheme's default port per the JDK's protocol handlers (80 for http, + * 443 for https, ...), or -1 when the JDK has no handler for the scheme — + * in which case no port is ever considered "default" and an explicit port + * always stays part of the origin. + */ + private static int defaultPort(URI uri) { + try { + return uri.toURL().getDefaultPort(); + } catch (MalformedURLException | IllegalArgumentException e) { + return -1; + } + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java index b8c85dae079..20756fef431 100644 --- a/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java @@ -1,10 +1,15 @@ package edu.harvard.iq.dataverse; +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; class DataverseSessionTest { @@ -28,4 +33,21 @@ void testCsrfTokenCanBeCleared() { assertFalse(session.matchesApiCsrfToken(token)); } + + @Test + void testCsrfTokenClearedWhenUserDemotedToGuest() { + // getUser(true) demotes deleted/deactivated users to guest; the CSRF + // token minted for the old identity must not survive the demotion. + DataverseSession session = new DataverseSession(); + AuthenticatedUser au = new AuthenticatedUser(); + au.setId(7L); + session.setUser(au); + String token = session.getOrCreateApiCsrfToken(); + + session.authenticationService = Mockito.mock(AuthenticationServiceBean.class); + when(session.authenticationService.findByID(7L)).thenReturn(null); // deleted + + assertEquals(GuestUser.get(), session.getUser(true)); + assertFalse(session.matchesApiCsrfToken(token)); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java index 0775182876a..8366bb06598 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java @@ -41,8 +41,8 @@ void testGetCsrfToken_HardeningDisabled_Returns400() throws Exception { 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); + // The auth-mechanism tag is absent for every non-session mechanism + // (api key, bearer, signed URL, ...) — the mock's default null models that. Response response = sut.getSessionCsrfToken(crc); @@ -70,6 +70,30 @@ void testGetCsrfToken_SessionCookieAuth_ReturnsToken() throws Exception { assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); String body = response.getEntity().toString(); assertTrue(body.contains("test-csrf-token-value")); + // The body carries a session secret; browsers and intermediaries must not cache it. + assertEquals("no-store", response.getHeaderString("Cache-Control")); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_PrivateUrlUserSession_ReturnsUnauthorized() throws Exception { + // PrivateUrlUser.isAuthenticated() is false and its preview sessions are exempt + // from hardening (read-only), so it is never asked for a token and is not issued + // one — symmetric with the AuthFilter exemption. + 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); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(new edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser(42L)); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); } @Test @@ -89,7 +113,7 @@ void testGetCsrfToken_SessionCookieAuthUnauthenticatedUser_ReturnsUnauthorized() Response response = sut.getSessionCsrfToken(crc); - // getRequestAuthenticatedUserOrDie throws WrappedResponse for non-authenticated users + // Guests hold no session-borne privileges and get no token. assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); } 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 f1c0fd9751e..237cf353c15 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 @@ -5,6 +5,7 @@ import edu.harvard.iq.dataverse.api.Users; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.SystemConfig; @@ -13,7 +14,6 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ResourceInfo; 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; @@ -27,515 +27,278 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * Unit tests for the session-cookie CSRF hardening in {@link AuthFilter}. + * + *

The filter's decisions depend only on the auth-mechanism tag, the user, + * the Origin/Referer/CSRF headers, and the resolved resource method (for the + * bootstrap exemption) — deliberately NOT on the HTTP method or request path, + * so no test stubs those. + */ @LocalJvmSettings class AuthFilterTest { + private static final String SITE_URL = "https://demo.dataverse.org"; + private static final String VALID_TOKEN = "valid-token"; + @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); + Fixture f = fixture(user, false); - sut.filter(requestContext); + f.sut.filter(f.requestContext); - verify(requestContext, never()).abortWith(any(Response.class)); - verify(requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + verify(f.requestContext, never()).abortWith(any(Response.class)); + verify(f.requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - 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(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)); + void testFilter_SessionCookie_MatchingOriginAndValidToken_Allowed() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieGetBlockedWithoutOriginAndCsrf() 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(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, Referer, or CSRF token - - inject(sut, "compoundAuthMechanism", compound); - inject(sut, "session", session); - inject(sut, "systemConfig", systemConfig); - - sut.filter(requestContext); + void testFilter_SessionCookie_MatchingRefererAndValidToken_Allowed() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.REFERER_HEADER)) + .thenReturn(SITE_URL + "/dataset.xhtml"); + givenValidToken(f); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); } + /** + * Browsers omit Origin on same-origin GETs, and Referrer-Policy: no-referrer + * suppresses Referer — a legitimate same-origin request can carry neither + * header. The CSRF token is then the deciding credential; failing closed here + * would make the hardening unusable on no-referrer installations. + */ @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresOriginAndCsrf() 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"); - 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)); + void testFilter_SessionCookie_NoOriginNoReferer_ValidTokenSufficient() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + verify(f.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"); - 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)); + void testFilter_SessionCookie_NoOriginNoRefererNoToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + + f.sut.filter(f.requestContext); + + assertForbidden(f); } @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); + void testFilter_SessionCookie_CrossOriginReferer_BlockedEvenWithValidToken() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.REFERER_HEADER)) + .thenReturn("https://evil.example/malicious"); + givenValidToken(f); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + assertForbidden(f); } @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - 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( - 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)); + void testFilter_SessionCookie_CrossOriginOrigin_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn("https://evil.example"); + + f.sut.filter(f.requestContext); + + assertForbidden(f); } @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); - inject(sut, "systemConfig", systemConfig); - - sut.filter(requestContext); + void testFilter_SessionCookie_MatchingOriginButMissingToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + // CSRF token header absent (mock returns null by default) - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + assertForbidden(f); } @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)); + void testFilter_SessionCookie_MatchingOriginButWrongToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + when(f.requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("wrong-token"); + when(f.session.matchesApiCsrfToken("wrong-token")).thenReturn(false); + + f.sut.filter(f.requestContext); + + assertForbidden(f); } + /** + * A guest session carries no privileges worth forging, so hardening + * short-circuits before the origin/token checks. + */ @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_GuestSessionCookieRequestSkipsHardening() 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(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { - ContainerRequestContext crc = invocation.getArgument(0); - crc.setProperty( - ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, - ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); - return GuestUser.get(); - }); - when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) - .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); - // No Origin/Referer and no CSRF token — would block an authenticated session, but a guest - // has no privileges to forge, so hardening must short-circuit before these checks. - - 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, GuestUser.get()); + void testFilter_GuestSessionCookieRequest_SkipsHardening() throws Exception { + Fixture f = sessionCookieFixture(GuestUser.get()); + // No Origin/Referer and no CSRF token — would block an authenticated session. + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + verify(f.requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, GuestUser.get()); } + /** + * PrivateUrlUser.isAuthenticated() is false, so a read-only private-URL + * preview session is exempt like a guest: hardening it adds no protection + * (no state to forge, responses not cross-origin readable) and the JSF + * preview flow that mints these sessions cannot obtain a token. + */ @Test @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") - void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAnyPath() 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); + void testFilter_PrivateUrlUserSessionCookieRequest_IsExemptLikeGuest() throws Exception { + Fixture f = sessionCookieFixture(new PrivateUrlUser(42L)); + // No Origin/Referer and no token — would block an authenticated session. - sut.filter(requestContext); + f.sut.filter(f.requestContext); - verify(requestContext, never()).abortWith(any(Response.class)); + verify(f.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); + void testFilter_NonSessionCookieMechanism_SkipsHardening() throws Exception { + // No auth-mechanism property set — how every non-session mechanism looks. + Fixture f = fixture(new AuthenticatedUser(), false); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); } @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); + void testFilter_CsrfBootstrapEndpoint_AllowedWithMatchingOriginAndNoToken() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); } + /** + * The bootstrap endpoint must also work when neither Origin nor Referer is + * sent (no-referrer installations): the request holds the session cookie, + * and the token it fetches is useless to a party that lacks that cookie. + */ @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); + void testFilter_CsrfBootstrapEndpoint_AllowedWithoutOriginHeaders() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); } @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); - inject(sut, "resourceInfo", mockBootstrapResourceInfo()); - - sut.filter(requestContext); - - verify(requestContext, never()).abortWith(any(Response.class)); + void testFilter_CsrfBootstrapEndpoint_CrossOriginBlocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn("https://evil.example"); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); + + f.sut.filter(f.requestContext); + + assertForbidden(f); } + /** + * With an unparseable site URL the allowed origin cannot be established: + * requests carrying origin headers fail closed, while header-less requests + * are still decided by the token (ConfigCheckService flags this state at + * startup). + */ @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"); + void testFilter_SessionCookie_UnparseableSiteUrl_OriginCarryingRequestBlocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.systemConfig.getDataverseSiteUrl()).thenReturn("not a url"); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + // ---- fixtures --------------------------------------------------------- + + private static final class Fixture { + AuthFilter sut; + ContainerRequestContext requestContext; + DataverseSession session; + SystemConfig systemConfig; + } + + private Fixture fixture(User user, boolean sessionCookieAuthenticated) throws Exception { + Fixture f = new Fixture(); + f.sut = new AuthFilter(); + f.requestContext = Mockito.mock(ContainerRequestContext.class); + f.session = Mockito.mock(DataverseSession.class); + f.systemConfig = Mockito.mock(SystemConfig.class); + when(f.systemConfig.getDataverseSiteUrl()).thenReturn(SITE_URL); + 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); - inject(sut, "resourceInfo", mockBootstrapResourceInfo()); - - sut.filter(requestContext); + when(compound.findUserFromRequest(f.requestContext)).thenReturn(user); + if (sessionCookieAuthenticated) { + when(f.requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } + + inject(f.sut, "compoundAuthMechanism", compound); + inject(f.sut, "session", f.session); + inject(f.sut, "systemConfig", f.systemConfig); + return f; + } - ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); - verify(requestContext).abortWith(abortResponseCaptor.capture()); - assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + private Fixture sessionCookieFixture(User user) throws Exception { + return fixture(user, true); } - 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 givenValidToken(Fixture f) { + when(f.requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn(VALID_TOKEN); + when(f.session.matchesApiCsrfToken(VALID_TOKEN)).thenReturn(true); + } + + private void assertForbidden(Fixture f) { + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(f.requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); } private ResourceInfo mockBootstrapResourceInfo() throws NoSuchMethodException { 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 799bed15187..9af2698d3c8 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 @@ -17,7 +17,7 @@ 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.never; import static org.mockito.Mockito.verify; @LocalJvmSettings @@ -37,9 +37,10 @@ public void testFindUserFromRequest_CanNotAuthenticateUserWithAnyMechanism() thr User actual = sut.findUserFromRequest(containerRequestContext); assertThat(actual, equalTo(GuestUser.get())); - verify(containerRequestContext, atLeastOnce()).setProperty( - ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, - ApiConstants.AUTH_MECHANISM_NONE); + // The auth-mechanism tag is only ever set for session-cookie auth; + // for everything else (including the guest fallback) it stays absent. + verify(containerRequestContext, never()).setProperty( + Mockito.eq(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM), Mockito.any()); } @Test