Skip to content

Commit 618ee4f

Browse files
committed
11744: CORS: echo request Origin and add Vary: Origin; sanitize CSV lists; prefer comma-separated origins; rely on JVM options/MicroProfile only; add tests and release notes
1 parent 684e0ba commit 618ee4f

3 files changed

Lines changed: 320 additions & 27 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# 11744: CORS header handling fixes (echo single Origin, add Vary: Origin, multi-origin allow, sanitization)
2+
3+
This branch adjusts the CORS filter so browser clients work correctly when multiple origins are allowed.
4+
5+
## What changed
6+
- Access-Control-Allow-Origin (ACAO) now echoes the single request `Origin` when it matches an allowlist from `dataverse.cors.origin`.
7+
- `Vary: Origin` is added when echoing a specific origin to keep caches correct across different origins.
8+
- Comma-separated origin lists are supported; surrounding quotes in CSV configs are stripped.
9+
- Sanitization is applied to CORS header lists (methods/allow/expose) to avoid quoted values that can break preflight checks.
10+
- Deprecated DB fallback for enabling CORS is removed; CORS is considered enabled only when `dataverse.cors.origin` is set as a JVM options/Microprofile setting.
11+
12+
## Upgrade / run notes (non-SQL)
13+
To keep CORS working after pulling this branch:
14+
15+
1) Configure origins as JVM options/Microprofile settings (no quotes):
16+
- Single origin:
17+
- `dataverse.cors.origin=https://example.org`
18+
- Multiple origins (comma-separated):
19+
- `dataverse.cors.origin=https://libis.github.io,https://gdcc.github.io`
20+
- Wildcard:
21+
- `dataverse.cors.origin=*`
22+
- Note: Browsers reject `*` when credentialed requests are used (cookies/Authorization headers). Prefer explicit origins for those cases.
23+
24+
2) Optional headers/methods lists (unquoted, comma-separated CSV):
25+
- `dataverse.cors.methods`
26+
- `dataverse.cors.headers.allow`
27+
- `dataverse.cors.headers.expose`
28+
29+
Avoid surrounding values with quotes (e.g., do not use `"Accept, Content-Type"`). Quotes will be stripped but may cause confusion.
30+
31+
3) If you previously relied on the database setting to enable CORS (deprecated `AllowCors`), set `dataverse.cors.origin` instead. The DB fallback is no longer used.
32+
33+
4) Reverse proxies/caches: `Vary: Origin` is now emitted. Ensure your proxy does not drop this header.
34+
35+
## Verify
36+
Preflight (replace DV_URL with your base URL):
37+
38+
```bash
39+
curl -i -X OPTIONS \
40+
-H "Origin: https://libis.github.io" \
41+
-H "Access-Control-Request-Method: GET" \
42+
"${DV_URL}/api/info/version"
43+
```
44+
45+
Expected:
46+
- `Access-Control-Allow-Origin: https://libis.github.io`
47+
- `Vary: Origin` present
48+
49+
Actual request:
50+
51+
```bash
52+
curl -i \
53+
-H "Origin: https://libis.github.io" \
54+
"${DV_URL}/api/info/version"
55+
```
56+
57+
Expected:
58+
- Same ACAO echo as above
59+
60+
## Backward compatibility
61+
- Instances relying on the deprecated DB-based CORS enablement must set `dataverse.cors.origin` to keep CORS enabled.
62+
- Quoted CORS configuration values may behave differently; remove quotes going forward.
Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,130 @@
11
package edu.harvard.iq.dataverse.filter;
22

3-
import jakarta.inject.Inject;
43
import jakarta.servlet.*;
54
import jakarta.servlet.annotation.WebFilter;
5+
import jakarta.servlet.http.HttpServletRequest;
66
import jakarta.servlet.http.HttpServletResponse;
77
import java.io.IOException;
8+
import java.util.Arrays;
9+
import java.util.Collections;
10+
import java.util.HashSet;
11+
import java.util.Set;
12+
import java.util.stream.Collectors;
813

914
import edu.harvard.iq.dataverse.settings.JvmSettings;
10-
import edu.harvard.iq.dataverse.settings.SettingsServiceBean;
1115

1216
/**
1317
* CorsFilter is a servlet filter that handles Cross-Origin Resource Sharing (CORS) for the Dataverse application.
1418
* It configures and applies CORS headers to HTTP responses based on application settings.
1519
*
1620
* This filter:
17-
* 1. Reads CORS configuration from JVM settings or (deprecated) the SettingsServiceBean. See the Dataverse Configuration Guide for more details.
21+
* 1. Reads CORS configuration from JVM options/Microprofile settings (e.g. dataverse.cors.*).
1822
* 2. Determines whether CORS should be allowed based on these settings.
19-
* 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.
23+
* 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.
2024
*
2125
* The filter is applied to all paths ("/*") in the application.
2226
*/
2327

2428
@WebFilter("/*")
2529
public class CorsFilter implements Filter {
26-
27-
@Inject
28-
private SettingsServiceBean settingsSvc;
29-
3030
private boolean allowCors;
31-
private String origin;
31+
private String origin; // raw configured origin value
3232
private String methods;
3333
private String allowHeaders;
3434
private String exposeHeaders;
35+
private Set<String> allowedOrigins = Collections.emptySet();
36+
private boolean allowAllOrigins = false;
3537

3638
@Override
3739
public void init(FilterConfig filterConfig) throws ServletException {
38-
origin = JvmSettings.CORS_ORIGIN.lookupOptional().orElse(null);
39-
boolean corsSetting = settingsSvc.isTrueForKey(SettingsServiceBean.Key.AllowCors, true);
40-
41-
if (origin == null && !corsSetting) {
42-
allowCors = false;
43-
} else {
44-
allowCors = true;
45-
origin = (origin != null) ? origin : "*";
46-
}
40+
origin = sanitize(JvmSettings.CORS_ORIGIN.lookupOptional().orElse(null));
41+
allowCors = origin != null && !origin.trim().isEmpty();
4742

4843
if (allowCors) {
49-
methods = JvmSettings.CORS_METHODS.lookupOptional().orElse("PUT, GET, POST, DELETE, OPTIONS");
50-
allowHeaders = JvmSettings.CORS_ALLOW_HEADERS.lookupOptional()
51-
.orElse("Accept, Content-Type, X-Dataverse-key, Range");
52-
exposeHeaders = JvmSettings.CORS_EXPOSE_HEADERS.lookupOptional()
53-
.orElse("Accept-Ranges, Content-Range, Content-Encoding");
44+
methods = sanitizeCsv(JvmSettings.CORS_METHODS.lookupOptional().orElse("GET, POST, OPTIONS, PUT, DELETE"));
45+
allowHeaders = sanitizeCsv(JvmSettings.CORS_ALLOW_HEADERS.lookupOptional()
46+
.orElse("Accept, Content-Type, X-Dataverse-key, Range"));
47+
exposeHeaders = sanitizeCsv(JvmSettings.CORS_EXPOSE_HEADERS.lookupOptional()
48+
.orElse("Accept-Ranges, Content-Range, Content-Encoding"));
49+
50+
// Initialize allowed origins (documented as comma-separated list)
51+
String configured = origin != null ? origin.trim() : null;
52+
if (configured == null || configured.isEmpty() || "*".equals(configured)) {
53+
allowAllOrigins = true;
54+
allowedOrigins = Collections.emptySet();
55+
} else {
56+
// Parse configured origins; code is tolerant of whitespace but
57+
// docs recommend a comma-separated list (no quotes)
58+
allowedOrigins = Arrays.stream(configured.split(","))
59+
.flatMap(s -> Arrays.stream(s.split("[\n\r\t ]+")))
60+
.map(String::trim)
61+
.filter(s -> !s.isEmpty())
62+
.collect(Collectors.toCollection(HashSet::new));
63+
// handle a single '"*"' that might slip through incorrectly
64+
if (allowedOrigins.size() == 1 && allowedOrigins.contains("*")) {
65+
allowAllOrigins = true;
66+
allowedOrigins = Collections.emptySet();
67+
}
68+
}
5469
}
5570
}
5671

5772
@Override
5873
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
5974
throws IOException, ServletException {
6075
if (allowCors) {
76+
HttpServletRequest request = (HttpServletRequest) servletRequest;
6177
HttpServletResponse response = (HttpServletResponse) servletResponse;
62-
response.addHeader("Access-Control-Allow-Origin", origin);
63-
response.addHeader("Access-Control-Allow-Methods", methods);
64-
response.addHeader("Access-Control-Allow-Headers", allowHeaders);
65-
response.addHeader("Access-Control-Expose-Headers", exposeHeaders);
78+
79+
String requestOrigin = sanitize(request.getHeader("Origin"));
80+
81+
// Decide ACAO value
82+
if (allowAllOrigins) {
83+
// Note: Browsers will reject '*' for credentialed requests; this is by design.
84+
response.setHeader("Access-Control-Allow-Origin", "*");
85+
} else if (requestOrigin != null && allowedOrigins.contains(requestOrigin)) {
86+
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
87+
// Help caches vary based on Origin
88+
response.setHeader("Vary", appendVary(response.getHeader("Vary"), "Origin"));
89+
}
90+
91+
response.setHeader("Access-Control-Allow-Methods", methods);
92+
response.setHeader("Access-Control-Allow-Headers", allowHeaders);
93+
response.setHeader("Access-Control-Expose-Headers", exposeHeaders);
6694
}
6795
chain.doFilter(servletRequest, servletResponse);
6896
}
97+
98+
/** Remove surrounding quotes and collapse internal quotes. */
99+
private String sanitize(String value) {
100+
if (value == null) return null;
101+
String v = value.trim();
102+
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
103+
v = v.substring(1, v.length() - 1);
104+
}
105+
return v.replace("\"", "").trim();
106+
}
107+
108+
/** Remove quotes from CSV-like header value and trim spaces around commas. */
109+
private String sanitizeCsv(String value) {
110+
String v = sanitize(value);
111+
// Normalize separators and trim tokens
112+
return Arrays.stream(v.split(","))
113+
.map(String::trim)
114+
.filter(s -> !s.isEmpty())
115+
.collect(Collectors.joining(", "));
116+
}
117+
118+
private String appendVary(String existing, String token) {
119+
if (existing == null || existing.isEmpty()) {
120+
return token;
121+
}
122+
// Avoid duplicate tokens in Vary
123+
Set<String> tokens = Arrays.stream(existing.split(","))
124+
.map(String::trim)
125+
.filter(s -> !s.isEmpty())
126+
.collect(Collectors.toCollection(HashSet::new));
127+
tokens.add(token);
128+
return String.join(", ", tokens);
129+
}
69130
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package edu.harvard.iq.dataverse.filter;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletRequest;
5+
import jakarta.servlet.ServletResponse;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import org.junit.jupiter.api.AfterEach;
9+
import org.junit.jupiter.api.BeforeEach;
10+
import org.junit.jupiter.api.Test;
11+
import org.mockito.ArgumentCaptor;
12+
13+
import java.util.HashMap;
14+
import java.util.Map;
15+
16+
import static org.junit.jupiter.api.Assertions.*;
17+
import static org.mockito.Mockito.*;
18+
19+
class CorsFilterTest {
20+
21+
private final Map<String, String> sysPropsBackup = new HashMap<>();
22+
23+
@BeforeEach
24+
void setUp() {
25+
// backup potentially touched props
26+
backupAndClear("dataverse.cors.origin");
27+
backupAndClear("dataverse.cors.methods");
28+
backupAndClear("dataverse.cors.headers.allow");
29+
backupAndClear("dataverse.cors.headers.expose");
30+
}
31+
32+
@AfterEach
33+
void tearDown() {
34+
restore("dataverse.cors.origin");
35+
restore("dataverse.cors.methods");
36+
restore("dataverse.cors.headers.allow");
37+
restore("dataverse.cors.headers.expose");
38+
}
39+
40+
@Test
41+
void wildcardOrigin_allowsAny_noVary() throws Exception {
42+
System.setProperty("dataverse.cors.origin", "*");
43+
44+
CorsFilter sut = new CorsFilter();
45+
injectSettingsAllowCors(sut, true);
46+
sut.init(null);
47+
48+
HttpServletRequest req = mock(HttpServletRequest.class);
49+
when(req.getHeader("Origin")).thenReturn("https://a.example");
50+
HttpServletResponse res = mock(HttpServletResponse.class);
51+
FilterChain chain = mock(FilterChain.class);
52+
53+
sut.doFilter(req, res, chain);
54+
55+
verify(res).setHeader("Access-Control-Allow-Origin", "*");
56+
// By design, Vary not required for wildcard
57+
verify(res, never()).setHeader(eq("Vary"), anyString());
58+
verify(chain).doFilter(any(ServletRequest.class), any(ServletResponse.class));
59+
}
60+
61+
@Test
62+
void singleOrigin_echoesAndAddsVary() throws Exception {
63+
System.setProperty("dataverse.cors.origin", "https://libis.github.io");
64+
65+
CorsFilter sut = new CorsFilter();
66+
injectSettingsAllowCors(sut, true);
67+
sut.init(null);
68+
69+
HttpServletRequest req = mock(HttpServletRequest.class);
70+
when(req.getHeader("Origin")).thenReturn("https://libis.github.io");
71+
HttpServletResponse res = mock(HttpServletResponse.class);
72+
when(res.getHeader("Vary")).thenReturn(null);
73+
FilterChain chain = mock(FilterChain.class);
74+
75+
sut.doFilter(req, res, chain);
76+
77+
verify(res).setHeader("Access-Control-Allow-Origin", "https://libis.github.io");
78+
79+
ArgumentCaptor<String> varyVal = ArgumentCaptor.forClass(String.class);
80+
verify(res).setHeader(eq("Vary"), varyVal.capture());
81+
assertTrue(varyVal.getValue().contains("Origin"));
82+
verify(chain).doFilter(any(ServletRequest.class), any(ServletResponse.class));
83+
}
84+
85+
@Test
86+
void multipleOrigins_echoesMatch_onlyWhenAllowed() throws Exception {
87+
// Comma-separated list as set via JVM options/Microprofile
88+
System.setProperty("dataverse.cors.origin", "https://a.example, https://b.example");
89+
90+
CorsFilter sut = new CorsFilter();
91+
injectSettingsAllowCors(sut, true);
92+
sut.init(null);
93+
94+
// allowed origin
95+
HttpServletRequest reqAllowed = mock(HttpServletRequest.class);
96+
when(reqAllowed.getHeader("Origin")).thenReturn("https://b.example");
97+
HttpServletResponse resAllowed = mock(HttpServletResponse.class);
98+
FilterChain chain = mock(FilterChain.class);
99+
100+
sut.doFilter(reqAllowed, resAllowed, chain);
101+
verify(resAllowed).setHeader("Access-Control-Allow-Origin", "https://b.example");
102+
verify(resAllowed).setHeader(eq("Vary"), contains("Origin"));
103+
104+
// not allowed origin -> no ACAO header set
105+
HttpServletRequest reqDenied = mock(HttpServletRequest.class);
106+
when(reqDenied.getHeader("Origin")).thenReturn("https://c.example");
107+
HttpServletResponse resDenied = mock(HttpServletResponse.class);
108+
109+
sut.doFilter(reqDenied, resDenied, chain);
110+
verify(resDenied, never()).setHeader(eq("Access-Control-Allow-Origin"), anyString());
111+
}
112+
113+
@Test
114+
void sanitizesQuotedHeaderLists() throws Exception {
115+
System.setProperty("dataverse.cors.origin", "https://x.example");
116+
System.setProperty("dataverse.cors.headers.allow", "\"Accept, X-Dataverse-key\"");
117+
System.setProperty("dataverse.cors.headers.expose", "\"Accept-Ranges, Content-Range\"");
118+
System.setProperty("dataverse.cors.methods", "GET, POST, OPTIONS");
119+
120+
CorsFilter sut = new CorsFilter();
121+
injectSettingsAllowCors(sut, true);
122+
sut.init(null);
123+
124+
HttpServletRequest req = mock(HttpServletRequest.class);
125+
when(req.getHeader("Origin")).thenReturn("https://x.example");
126+
HttpServletResponse res = mock(HttpServletResponse.class);
127+
128+
sut.doFilter(req, res, mock(FilterChain.class));
129+
130+
verify(res).setHeader("Access-Control-Allow-Headers", "Accept, X-Dataverse-key");
131+
verify(res).setHeader("Access-Control-Expose-Headers", "Accept-Ranges, Content-Range");
132+
verify(res).setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
133+
}
134+
135+
@Test
136+
void disabledCors_skipsHeaders() throws Exception {
137+
// no origin set -> CORS disabled
138+
CorsFilter sut = new CorsFilter();
139+
sut.init(null);
140+
141+
HttpServletRequest req = mock(HttpServletRequest.class);
142+
when(req.getHeader("Origin")).thenReturn("https://any.example");
143+
HttpServletResponse res = mock(HttpServletResponse.class);
144+
145+
sut.doFilter(req, res, mock(FilterChain.class));
146+
147+
verify(res, never()).setHeader(eq("Access-Control-Allow-Origin"), anyString());
148+
verify(res, never()).setHeader(eq("Access-Control-Allow-Methods"), anyString());
149+
verify(res, never()).setHeader(eq("Access-Control-Allow-Headers"), anyString());
150+
verify(res, never()).setHeader(eq("Access-Control-Expose-Headers"), anyString());
151+
}
152+
153+
// No-op since filter no longer depends on SettingsServiceBean
154+
private void injectSettingsAllowCors(CorsFilter sut, boolean allowCors) { /* legacy path removed */ }
155+
156+
private void backupAndClear(String key) {
157+
String old = System.getProperty(key);
158+
if (old != null) {
159+
sysPropsBackup.put(key, old);
160+
}
161+
System.clearProperty(key);
162+
}
163+
164+
private void restore(String key) {
165+
System.clearProperty(key);
166+
if (sysPropsBackup.containsKey(key)) {
167+
System.setProperty(key, sysPropsBackup.get(key));
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)