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
18 changes: 15 additions & 3 deletions src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import edu.harvard.iq.dataverse.settings.JvmSettings;
Copy link
Member

Choose a reason for hiding this comment

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

I'm just going to put this comment at the top but can you please add a release not snippet that describes the bug being fixed? Thanks! I think you know the drill, but just in case: https://guides.dataverse.org/en/6.9/developers/version-control.html#writing-a-release-note-snippet

import edu.harvard.iq.dataverse.util.ListSplitUtil;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
Expand All @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions src/test/java/edu/harvard/iq/dataverse/api/CorsIT.java
Original file line number Diff line number Diff line change
@@ -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<String> getExpectedCorsMethods() {
return List.of("GET", "POST", "OPTIONS", "PUT", "DELETE");
}

private static List<String> getExpectedCorsAllowHeaders() {
return List.of("Accept", "Content-Type", "X-Dataverse-key", "Range");
}

private static List<String> getExpectedCorsExposeHeaders() {
return List.of("Accept-Ranges", "Content-Range", "Content-Encoding");
}

private static void assertHeaderSetEquals(String headerName, List<String> expectedTokens, Response response) {
String headerValue = response.getHeader(headerName);
assertTrue(headerValue != null && !headerValue.isBlank(), "Missing header: " + headerName);
Set<String> actual = normalizeTokens(headerValue);
Set<String> expected = expectedTokens.stream()
.map(CorsIT::normalizeToken)
.collect(Collectors.toCollection(HashSet::new));
assertEquals(expected, actual, "Unexpected value for header: " + headerName);
}

private static Set<String> 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);
}
}
2 changes: 1 addition & 1 deletion tests/integration-tests.txt
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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

No spaces allowed.