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..b5bc7f02131 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java @@ -0,0 +1,107 @@ +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.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; +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(); + } + + @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" + }) + public void testPreflightOptionsCorsHeaders(String path) { + assertPreflightCorsHeaders(path); + } + + 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") + .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); + 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); + } +} 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