`,
but this is controlled by other, store-specific settings.
@@ -3786,21 +3816,38 @@ dataverse.search.default-service
Experimental. See :doc:`/developers/search-services`.
-.. _dataverse.cors:
-
-CORS Settings
-+++++++++++++
-
-The following settings control Cross-Origin Resource Sharing (CORS) for your Dataverse installation.
-
.. _dataverse.cors.origin:
dataverse.cors.origin
+++++++++++++++++++++
-Allowed origins for CORS requests. If this setting is not defined, CORS headers are not added. Set to ``*`` to allow all origins (note that browsers will not allow credentialed requests with ``*``) or provide a comma-separated list of explicit origins.
+Allowed origins for CORS requests. See also :ref:`dataverse.cors`.
+
+Default: *not configured*
-Multiple origins can be specified as a comma-separated list (whitespace is ignored):
+.. warning:: | If this setting is not explicitly configured, no CORS headers at all are added to responses.
+ | The default policy (see all CORS related settings) is still being enforced!
+
+.. list-table::
+ :align: left
+ :widths: 10 10 80
+ :header-rows: 1
+ :stub-columns: 1
+
+ * - Type
+ - Value/Example
+ - Description
+ * - Wildcard
+ - ``*``
+ - - Allow access from all origins.
+ - Response header echoes ``Access-Control-Allow-Origin: *``
+ - Browsers will not allow credentialed requests with this setting.
+ * - List of Origins
+ - ``https://example.org, https://example.com``
+ - - Comma separated, white space ignored.
+ - Single matching request ``Origin`` header echoed as response header ``Access-Control-Allow-Origin``.
+ - ``Vary: Origin`` header added to support correct proxy/CDN caching.
+ - Use ``${dataverse.siteurl}`` to dynamically add the installation's URL to the list.
Example:
@@ -3808,18 +3855,14 @@ Example:
Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_CORS_ORIGIN``.
-Behavior:
-
-* When a list of origins is configured, Dataverse echoes the single matching request ``Origin`` value in ``Access-Control-Allow-Origin`` and adds ``Vary: Origin`` to support correct proxy/CDN caching.
-* When ``*`` is configured, ``Access-Control-Allow-Origin: *`` is sent and ``Vary`` is not modified.
-
.. _dataverse.cors.methods:
dataverse.cors.methods
++++++++++++++++++++++
-Allowed HTTP methods for CORS requests. The default when this setting is missing is "GET,POST,OPTIONS,PUT,DELETE".
-Multiple methods can be specified as a comma-separated list.
+Allowed HTTP methods for CORS requests as a comma separated list. Whitespace is ignored.
+
+Default: ``GET,POST,OPTIONS,PUT,DELETE``
Example:
@@ -3832,8 +3875,9 @@ Can also be set via any `supported MicroProfile Config API source`_, e.g. the en
dataverse.cors.headers.allow
++++++++++++++++++++++++++++
-Allowed headers for CORS requests. The default when this setting is missing is "Accept,Content-Type,X-Dataverse-key,Range".
-Multiple headers can be specified as a comma-separated list.
+Allowed headers for CORS requests as a comma separated list. Whitespace is ignored.
+
+Default: ``Accept, Content-Type, X-Dataverse-key, Range``
Example:
@@ -3846,8 +3890,9 @@ Can also be set via any `supported MicroProfile Config API source`_, e.g. the en
dataverse.cors.headers.expose
+++++++++++++++++++++++++++++
-Headers to expose in CORS responses. The default when this setting is missing is "Accept-Ranges,Content-Range,Content-Encoding".
-Multiple headers can be specified as a comma-separated list.
+Headers to expose in CORS responses as a comma separated list. Whitespace is ignored.
+
+Default: ``Accept-Ranges, Content-Range, Content-Encoding``
Example:
diff --git a/doc/sphinx-guides/source/installation/index.rst b/doc/sphinx-guides/source/installation/index.rst
index a0a88700d3d..bdfb4cc8037 100755
--- a/doc/sphinx-guides/source/installation/index.rst
+++ b/doc/sphinx-guides/source/installation/index.rst
@@ -16,6 +16,7 @@ Installation Guide
prerequisites
installation-main
config
+ big-data-support
upgrading
shibboleth
oauth2
diff --git a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java
index d7f14fff245..a27996e47e6 100644
--- a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java
+++ b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java
@@ -9,6 +9,7 @@
import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.util.ListSplitUtil;
+import jakarta.servlet.DispatcherType;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
@@ -27,11 +28,22 @@
* 1. Reads CORS configuration from JVM settings (dataverse.cors.*). See the Dataverse Configuration Guide for more details.
* 2. Determines whether CORS should be allowed based on these settings.
* 3. If CORS is allowed, it adds the appropriate CORS headers to all HTTP responses. The JVMSettings allow customization of the header contents if desired.
- *
+ *
+ * The broader dispatcher set is intentional:
+ * - REQUEST applies CORS to direct client requests.
+ * - FORWARD covers internal forwards, including API paths rewritten by
+ * {@link edu.harvard.iq.dataverse.api.ApiRouter} from {@code /api/...} to {@code /api/v1/...}.
+ * - ERROR ensures error responses also carry CORS headers, so browser clients can read error details.
+ * - ASYNC keeps behavior consistent for asynchronous servlet/JAX-RS processing.
+ *
* The filter is applied to all paths ("/*") in the application.
*/
-
-@WebFilter("/*")
+@WebFilter(value = "/*", dispatcherTypes = {
+ DispatcherType.REQUEST,
+ DispatcherType.FORWARD,
+ DispatcherType.ERROR,
+ DispatcherType.ASYNC
+})
public class CorsFilter implements Filter {
private boolean allowCors;
diff --git a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java
new file mode 100644
index 00000000000..630fa2b982a
--- /dev/null
+++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java
@@ -0,0 +1,111 @@
+package edu.harvard.iq.dataverse.api;
+
+import io.restassured.RestAssured;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.blankOrNullString;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+
+/**
+ * Integration tests for CORS headers on API endpoints. These tests verify that the expected CORS
+ * headers are present and contain the correct values for preflight OPTIONS requests to key
+ * API endpoints.
+ *
+ * For this to work CORS has to be enabled. Eg. in docker-compose-dev.yml add
+ * DATAVERSE_CORS_ORIGIN: "*"
+ * env to `dev_dataverse`.
+ */
+class CorsIT {
+ @BeforeAll
+ static void setUp() {
+ RestAssured.baseURI = UtilIT.getRestAssuredBaseUri();
+ }
+
+ /**
+ * Tests the presence of CORS preflight headers on various subsystems by sending HTTP OPTIONS requests
+ * to specified paths and validating the responses for expected headers and status.
+ * These paths are served by different servlets, filters, and frameworks.
+ * Nonetheless, any of them should present CORS headers when configured.
+ *
+ *
+ * TODO: Currently, this test relies on the CI infrastructure executing the test to have set at least
+ * the JVM setting dataverse.cors.origin. Otherwise, no headers will be sent.
+ *
+ *
+ *
+ * NOTE: At the time of writing (2026-04), there is no infrastructure available to a) manipulate
+ * these settings in this end-to-end testing scenario nor b) to dynamically reload the test subject.
+ * It is initialized once at deployment time, which would require isolating this test some other way.
+ * Thus, only the presence of headers is checked, but not its content
+ * (which is fine, given the scope of the test).
+ *
+ *
+ * @param path the relative path on the subsystem to which the CORS preflight request is sent
+ */
+ @ParameterizedTest(name = "CORS preflight headers on {0}")
+ @ValueSource(strings = {
+ "/api/dataverses/root/datasets",
+ "/api/v1/dataverses/root/datasets",
+ "/page_doesnt_exist",
+ "/dvn/api/data-deposit/v1.1/swordv2/collection/dataverse/root"
+ })
+ void ensurePresenceOnDifferentSubsystems(String path) {
+ given()
+ .header("Accept", "*/*")
+ .header("Accept-Language", "en-US,en;q=0.9,es;q=0.8,hu;q=0.7")
+ .header("Access-Control-Request-Headers", "content-type,x-dataverse-key")
+ .header("Access-Control-Request-Method", "POST")
+ .header("Origin", "null")
+ .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+ .when()
+ .options(path)
+ .then()
+ .log().ifValidationFails()
+ .statusCode(anyOf(is(200), is(204)))
+ .header("Access-Control-Allow-Methods", not(blankOrNullString()))
+ .header("Access-Control-Allow-Headers", not(blankOrNullString()))
+ .header("Access-Control-Expose-Headers", not(blankOrNullString()));
+ }
+
+ /*
+ The following code may be used in a future test enabling assertions of header contents:
+
+ Usage:
+ assertHeaderSetEquals("Access-Control-Allow-Methods", expectedCorsMethods, response);
+ assertHeaderSetEquals("Access-Control-Allow-Headers", expectedCorsAllowHeaders, response);
+ assertHeaderSetEquals("Access-Control-Expose-Headers", expectedCorsExposeHeaders, response);
+
+ Class fields:
+ private final List expectedCorsMethods = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS");
+ private final List expectedCorsAllowHeaders = List.of("Accept", "Content-Type", "X-Dataverse-key", "Range");
+ private final List expectedCorsExposeHeaders = List.of("Accept-Ranges", "Content-Range", "Content-Encoding");
+
+ Assertions methods:
+ private static void assertHeaderSetEquals(String headerName, List expectedTokens, Response response) {
+ String headerValue = response.getHeader(headerName);
+ assertTrue(headerValue != null && !headerValue.isBlank(), "Missing header: " + headerName);
+ Set actual = normalizeTokens(headerValue);
+ Set expected = expectedTokens.stream()
+ .map(CorsIT::normalizeToken)
+ .collect(Collectors.toCollection(HashSet::new));
+ assertEquals(expected, actual, "Unexpected value for header: " + headerName);
+ }
+
+ private static Set normalizeTokens(String headerValue) {
+ return Arrays.stream(headerValue.split(","))
+ .map(CorsIT::normalizeToken)
+ .filter(token -> !token.isEmpty())
+ .collect(Collectors.toCollection(HashSet::new));
+ }
+
+ private static String normalizeToken(String value) {
+ return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ */
+}
diff --git a/tests/integration-tests.txt b/tests/integration-tests.txt
index 51253928df9..c271657eaac 100644
--- a/tests/integration-tests.txt
+++ b/tests/integration-tests.txt
@@ -1 +1 @@
-DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,HarvestingClientsIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT,NotificationsIT,BagIT,MetadataBlocksIT,NetcdfIT,SignpostingIT,FitsIT,LogoutIT,DataRetrieverApiIT,ProvIT,S3AccessIT,OpenApiIT,InfoIT,DatasetFieldsIT,SavedSearchIT,DatasetTypesIT,DataverseFeaturedItemsIT,SendFeedbackApiIT,CustomizationIT,JsonLDExportIT,WorkflowsIT,LDNInboxIT,LocalContextsIT
+DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,HarvestingClientsIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT,NotificationsIT,BagIT,MetadataBlocksIT,NetcdfIT,SignpostingIT,FitsIT,LogoutIT,DataRetrieverApiIT,ProvIT,S3AccessIT,OpenApiIT,InfoIT,DatasetFieldsIT,SavedSearchIT,DatasetTypesIT,DataverseFeaturedItemsIT,SendFeedbackApiIT,CustomizationIT,JsonLDExportIT,WorkflowsIT,LDNInboxIT,LocalContextsIT,CorsIT