Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions doc/release-notes/12178-session-cookie-api-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 31 additions & 3 deletions doc/sphinx-guides/source/api/native-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down Expand Up @@ -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"

74 changes: 73 additions & 1 deletion doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
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 </container/base-image>`. See also Payara's `documentation <https://docs.payara.fish/community/docs/6.2024.6/Technical%20Documentation/Payara%20Server%20Documentation/General%20Administration/Administering%20HTTP%20Connectivity.html>`_ 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:

Expand Down Expand Up @@ -3898,7 +3899,78 @@
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 <https://github.com/IQSS/dataverse/issues/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
+++++++++++++++++++++++++++++++++++++++++++

Check failure on line 3908 in doc/sphinx-guides/source/installation/config.rst

View workflow job for this annotation

GitHub Actions / docs

Title underline too short.

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-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``.
- 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://<your-dataverse-host>/ | 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:

Expand Down
22 changes: 22 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/DataverseSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +89,7 @@ public void setDismissedMessages(List<BannerMessage> dismissedMessages) {
* leave the state alone (see setDebug()).
*/
private Boolean debug;
private String apiCsrfToken;

public User getUser() {
return getUser(false);
Expand Down Expand Up @@ -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() {
Comment on lines +131 to +142
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getOrCreateApiCsrfToken() lazily initializes apiCsrfToken without any synchronization. Because DataverseSession is session-scoped, concurrent requests on the same session can race and generate/overwrite different tokens, causing intermittent CSRF validation failures for legitimate clients. Consider making token initialization atomic (e.g., synchronize initialization, use an AtomicReference, or generate once in a @PostConstruct), so a token can’t be replaced mid-session by a parallel request.

Suggested change
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() {
public synchronized String getOrCreateApiCsrfToken() {
if (apiCsrfToken == null || apiCsrfToken.isBlank()) {
apiCsrfToken = UUID.randomUUID().toString();
}
return apiCsrfToken;
}
public synchronized boolean matchesApiCsrfToken(String token) {
return token != null && apiCsrfToken != null && apiCsrfToken.equals(token);
}
public synchronized void clearApiCsrfToken() {

Copilot uses AI. Check for mistakes.
apiCsrfToken = null;
}

/**
* Sets the user and configures the session timeout.
*/
Expand All @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading