From e7e9f18f997f13e7ad37342f78fc378b2923df65 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 13:23:33 +0100 Subject: [PATCH 1/9] Fix CorsFilter invocation inconsistency When accessing /api/... endpoints the CorsFilter was not always hit and so these endpoints could not be used from webapps even if CORS was properly configured. The problem seemed to be with ApiRouter's forwarding mechanism from /api/... to /api/v1/... Adding FORWARD dispatcher types to @WebFilter solves this. Fix is based on this zulip thread: https://dataverse.zulipchat.com/#narrow/channel/379673-dev/topic/CorsFilter.20Servlet.20Filter.20not.20discovered/near/572358009 --- .../java/edu/harvard/iq/dataverse/filter/CorsFilter.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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..c1174dafc99 100644 --- a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java @@ -9,12 +9,7 @@ import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.ListSplitUtil; -import jakarta.servlet.Filter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.FilterConfig; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; +import jakarta.servlet.*; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -31,7 +26,7 @@ * The filter is applied to all paths ("/*") in the application. */ -@WebFilter("/*") +@WebFilter(value = "/*", dispatcherTypes = { DispatcherType.REQUEST, DispatcherType.FORWARD}) public class CorsFilter implements Filter { private boolean allowCors; From ccfe364acb7af666d66783a58fe7d8d6ec516cbe Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 13:23:33 +0100 Subject: [PATCH 2/9] Fix imports --- .../java/edu/harvard/iq/dataverse/filter/CorsFilter.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 c1174dafc99..0a3afabdcba 100644 --- a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java @@ -9,7 +9,13 @@ import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.ListSplitUtil; -import jakarta.servlet.*; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; From 860bd51b4f88916d6667b570faa30990556e02d0 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 13:52:55 +0100 Subject: [PATCH 3/9] Add CorsIT for minimal CORS header checking --- .../edu/harvard/iq/dataverse/api/CorsIT.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java 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..75293a6dc4f --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java @@ -0,0 +1,92 @@ +package edu.harvard.iq.dataverse.api; + +import io.restassured.RestAssured; +import io.restassured.response.Response; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 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`. + */ +public class CorsIT +{ + private static final String ORIGIN_NULL = "null"; + + @BeforeAll + public static void setUp() { + RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); + } + + @Test + public void testPreflightOptionsCorsHeaders() { + assertPreflightCorsHeaders("/api/dataverses/root/datasets"); + assertPreflightCorsHeaders("/api/v1/dataverses/root/datasets"); + } + + private void assertPreflightCorsHeaders(String path) { + Response response = 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", 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") + .options(path); + + int statusCode = response.getStatusCode(); + assertTrue(statusCode == 200 || statusCode == 204, "Expected 200 or 204 but got " + statusCode); + + assertHeaderSetEquals("Access-Control-Allow-Methods", getExpectedCorsMethods(), response); + assertHeaderSetEquals("Access-Control-Allow-Headers", getExpectedCorsAllowHeaders(), response); + assertHeaderSetEquals("Access-Control-Expose-Headers", getExpectedCorsExposeHeaders(), response); + } + + private static List getExpectedCorsMethods() { + return List.of("GET", "POST", "OPTIONS", "PUT", "DELETE"); + } + + private static List getExpectedCorsAllowHeaders() { + return List.of("Accept", "Content-Type", "X-Dataverse-key", "Range"); + } + + private static List getExpectedCorsExposeHeaders() { + return List.of("Accept-Ranges", "Content-Range", "Content-Encoding"); + } + + 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); + } +} From c7c3c686639c5700c53892096417fccd10c65b97 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 14:39:03 +0100 Subject: [PATCH 4/9] Make it ParameterizedTest, add more endpoints --- .../edu/harvard/iq/dataverse/api/CorsIT.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java index 75293a6dc4f..cc373b04b71 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java @@ -7,9 +7,11 @@ import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.stream.Stream; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -27,16 +29,26 @@ public class CorsIT { private static final String ORIGIN_NULL = "null"; + private static final List PRE_FLIGHT_ENDPOINTS = List.of( + "/api/dataverses/root/datasets", + "/api/v1/dataverses/root/datasets", + "/page_doesnt_exist", + "/dvn/api/data-deposit/v1.1/swordv2/collection/dataverse/root" + ); @BeforeAll public static void setUp() { RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); } - @Test - public void testPreflightOptionsCorsHeaders() { - assertPreflightCorsHeaders("/api/dataverses/root/datasets"); - assertPreflightCorsHeaders("/api/v1/dataverses/root/datasets"); + @ParameterizedTest(name = "CORS preflight headers on {0}") + @MethodSource("preflightEndpoints") + public void testPreflightOptionsCorsHeaders(String path) { + assertPreflightCorsHeaders(path); + } + + private static Stream preflightEndpoints() { + return PRE_FLIGHT_ENDPOINTS.stream(); } private void assertPreflightCorsHeaders(String path) { From 140a79926e5845f19d63a72398d31738023a51ec Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 14:46:51 +0100 Subject: [PATCH 5/9] Fix code style --- src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java index cc373b04b71..0aa4a1c7da4 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java @@ -26,8 +26,7 @@ * DATAVERSE_CORS_ORIGIN: "*" * env to `dev_dataverse`. */ -public class CorsIT -{ +public class CorsIT { private static final String ORIGIN_NULL = "null"; private static final List PRE_FLIGHT_ENDPOINTS = List.of( "/api/dataverses/root/datasets", From 81a4e32f99e51b8fc1801f7af05623a89b5d7c05 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 17:28:39 +0100 Subject: [PATCH 6/9] Add ERROR and ASYNC dispatcher type and more docs --- .../harvard/iq/dataverse/filter/CorsFilter.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 0a3afabdcba..a27996e47e6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java @@ -28,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(value = "/*", dispatcherTypes = { DispatcherType.REQUEST, DispatcherType.FORWARD}) +@WebFilter(value = "/*", dispatcherTypes = { + DispatcherType.REQUEST, + DispatcherType.FORWARD, + DispatcherType.ERROR, + DispatcherType.ASYNC +}) public class CorsFilter implements Filter { private boolean allowCors; From 36c336e31452362be3cfba35bed1f6c7dfffb73e Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Fri, 6 Feb 2026 17:34:17 +0100 Subject: [PATCH 7/9] Use idiomatic RestAssured assertions --- .../edu/harvard/iq/dataverse/api/CorsIT.java | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java index 0aa4a1c7da4..b5bc7f02131 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java @@ -7,13 +7,16 @@ import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.stream.Stream; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; +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; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,12 +31,6 @@ */ public class CorsIT { private static final String ORIGIN_NULL = "null"; - private static final List PRE_FLIGHT_ENDPOINTS = List.of( - "/api/dataverses/root/datasets", - "/api/v1/dataverses/root/datasets", - "/page_doesnt_exist", - "/dvn/api/data-deposit/v1.1/swordv2/collection/dataverse/root" - ); @BeforeAll public static void setUp() { @@ -41,15 +38,16 @@ public static void setUp() { } @ParameterizedTest(name = "CORS preflight headers on {0}") - @MethodSource("preflightEndpoints") + @ValueSource(strings = { + "/api/dataverses/root/datasets", + "/api/v1/dataverses/root/datasets", + "/page_doesnt_exist", + "/dvn/api/data-deposit/v1.1/swordv2/collection/dataverse/root" + }) public void testPreflightOptionsCorsHeaders(String path) { assertPreflightCorsHeaders(path); } - private static Stream preflightEndpoints() { - return PRE_FLIGHT_ENDPOINTS.stream(); - } - private void assertPreflightCorsHeaders(String path) { Response response = given() .header("Accept", "*/*") @@ -58,10 +56,16 @@ private void assertPreflightCorsHeaders(String path) { .header("Access-Control-Request-Method", "POST") .header("Origin", 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") - .options(path); - - int statusCode = response.getStatusCode(); - assertTrue(statusCode == 200 || statusCode == 204, "Expected 200 or 204 but got " + statusCode); + .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())) + .extract() + .response(); assertHeaderSetEquals("Access-Control-Allow-Methods", getExpectedCorsMethods(), response); assertHeaderSetEquals("Access-Control-Allow-Headers", getExpectedCorsAllowHeaders(), response); From 2a85be789480e44f38625ad93db3658f6643fb40 Mon Sep 17 00:00:00 2001 From: "Balazs E. Pataki" Date: Thu, 12 Feb 2026 14:43:44 +0100 Subject: [PATCH 8/9] Add CorsIT to integration-tests.txt --- tests/integration-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration-tests.txt b/tests/integration-tests.txt index 51253928df9..aa789397bd3 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 From 5b74dd01befac4046a8e5c858f5a52be9de2c1e6 Mon Sep 17 00:00:00 2001 From: beep Date: Fri, 13 Feb 2026 11:11:43 +0100 Subject: [PATCH 9/9] Update tests/integration-tests.txt Co-authored-by: Oliver Bertuch --- tests/integration-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration-tests.txt b/tests/integration-tests.txt index aa789397bd3..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, CorsIT +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