Skip to content

Commit 5c18694

Browse files
g-gallotti_globantBeta Bot
authored andcommitted
Cherry pick branch 'genexuslabs:fix/cors-implementation-master' into beta
1 parent a2b3bd4 commit 5c18694

6 files changed

Lines changed: 355 additions & 41 deletions

File tree

wrappercommon/src/main/java/com/genexus/cors/CORSHelper.java

Lines changed: 90 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,72 +3,130 @@
33
import com.genexus.common.interfaces.SpecificImplementation;
44

55
import java.util.HashMap;
6+
import java.util.LinkedHashMap;
67
import java.util.List;
78
import java.util.Map;
9+
import java.util.function.Supplier;
810

911
public class CORSHelper {
10-
public static String REQUEST_METHOD_HEADER_NAME = "Access-Control-Request-Method";
11-
public static String REQUEST_HEADERS_HEADER_NAME = "Access-Control-Request-Headers";
12+
public static final String REQUEST_METHOD_HEADER_NAME = "Access-Control-Request-Method";
13+
public static final String REQUEST_HEADERS_HEADER_NAME = "Access-Control-Request-Headers";
14+
public static final String ORIGIN_HEADER_NAME = "Origin";
1215

13-
private static String CORS_ALLOWED_ORIGIN = "CORS_ALLOW_ORIGIN";
14-
private static String CORS_MAX_AGE_SECONDS = "86400";
15-
private static String PREFLIGHT_REQUEST = "OPTIONS";
16+
private static final String CORS_ALLOWED_ORIGIN_PROPERTY = "CORS_ALLOW_ORIGIN";
17+
private static final String CORS_MAX_AGE_SECONDS = "86400";
18+
private static final String PREFLIGHT_REQUEST = "OPTIONS";
19+
private static final String WILDCARD = "*";
20+
21+
// Test seam: tests can replace this to avoid wiring SpecificImplementation.
22+
static Supplier<String> allowedOriginSupplier = CORSHelper::readAllowedOriginFromConfig;
1623

1724
public static boolean corsSupportEnabled() {
18-
return getAllowedOrigin() != null;
25+
return getConfiguredAllowedOrigin() != null;
1926
}
2027

28+
/** Build CORS headers from a multi-valued header map (JAX-RS style). */
2129
public static HashMap<String, String> getCORSHeaders(String httpMethod, Map<String, List<String>> headers) {
22-
if (getAllowedOrigin() == null) {
23-
return null;
24-
}
30+
return corsHeaders(httpMethod,
31+
getHeaderValue(ORIGIN_HEADER_NAME, headers),
32+
getHeaderValue(REQUEST_METHOD_HEADER_NAME, headers),
33+
getHeaderValue(REQUEST_HEADERS_HEADER_NAME, headers));
34+
}
2535

26-
String requestedMethod = getHeaderValue(REQUEST_METHOD_HEADER_NAME, headers);
27-
String requestedHeaders = getHeaderValue(REQUEST_HEADERS_HEADER_NAME, headers);
36+
/** Build CORS headers from individual header values (Servlet style). */
37+
public static HashMap<String, String> getCORSHeaders(String httpMethod, String origin, String requestedMethod, String requestedHeaders) {
38+
return corsHeaders(httpMethod, origin, requestedMethod, requestedHeaders);
39+
}
2840

29-
return corsHeaders(httpMethod, requestedMethod, requestedHeaders);
41+
/** True iff this request looks like a CORS preflight (OPTIONS + Origin + Access-Control-Request-Method). */
42+
public static boolean isPreflight(String httpMethod, String origin, String requestedMethod) {
43+
return httpMethod != null
44+
&& PREFLIGHT_REQUEST.equalsIgnoreCase(httpMethod)
45+
&& origin != null && !origin.isEmpty()
46+
&& requestedMethod != null && !requestedMethod.isEmpty();
3047
}
3148

32-
public static HashMap<String, String> getCORSHeaders(String httpMethod, String requestedMethod, String requestedHeaders) {
33-
return corsHeaders(httpMethod, requestedMethod, requestedHeaders);
49+
private static String getConfiguredAllowedOrigin() {
50+
String value = allowedOriginSupplier.get();
51+
return (value == null || value.isEmpty()) ? null : value;
3452
}
3553

36-
private static String getAllowedOrigin() {
37-
String corsAllowedOrigin = SpecificImplementation.Application.getClientPreferences().getProperty(CORS_ALLOWED_ORIGIN, "");
38-
if (corsAllowedOrigin == null || corsAllowedOrigin.isEmpty()) {
54+
private static String readAllowedOriginFromConfig() {
55+
if (SpecificImplementation.Application == null) {
3956
return null;
4057
}
41-
return corsAllowedOrigin;
58+
return SpecificImplementation.Application.getClientPreferences().getProperty(CORS_ALLOWED_ORIGIN_PROPERTY, "");
4259
}
4360

44-
private static HashMap<String, String> corsHeaders(String httpMethodName, String requestedMethod, String requestedHeaders) {
45-
String corsAllowedOrigin = getAllowedOrigin();
46-
if (corsAllowedOrigin == null) {
61+
/**
62+
* Resolve the value to send in Access-Control-Allow-Origin, or null when the
63+
* request origin is not in the configured allowlist (no CORS headers should be emitted).
64+
*
65+
* Configuration accepts:
66+
* "*" -> allow any origin (without credentials, per spec)
67+
* "https://a.example" -> single origin
68+
* "https://a.example,https://b.test" -> allowlist
69+
*/
70+
private static String resolveAllowedOrigin(String configuredOrigin, String requestOrigin) {
71+
if (requestOrigin == null || requestOrigin.isEmpty()) {
4772
return null;
4873
}
74+
if (WILDCARD.equals(configuredOrigin.trim())) {
75+
return WILDCARD;
76+
}
77+
for (String allowed : configuredOrigin.split(",")) {
78+
String candidate = allowed.trim();
79+
if (!candidate.isEmpty() && candidate.equals(requestOrigin)) {
80+
return candidate;
81+
}
82+
}
83+
return null;
84+
}
4985

50-
boolean isPreflightRequest = httpMethodName.equalsIgnoreCase(PREFLIGHT_REQUEST);
86+
private static HashMap<String, String> corsHeaders(String httpMethodName, String origin, String requestedMethod, String requestedHeaders) {
87+
String configuredOrigin = getConfiguredAllowedOrigin();
88+
if (configuredOrigin == null) return null;
5189

52-
HashMap<String, String> corsHeaders = new HashMap<>();
53-
corsHeaders.put("Access-Control-Allow-Origin", corsAllowedOrigin);
54-
corsHeaders.put("Access-Control-Allow-Credentials", "true");
55-
corsHeaders.put("Access-Control-Max-Age", CORS_MAX_AGE_SECONDS);
90+
String allowOriginValue = resolveAllowedOrigin(configuredOrigin, origin);
91+
if (allowOriginValue == null) return null;
5692

57-
if (isPreflightRequest && requestedHeaders != null && !requestedHeaders.isEmpty()) {
58-
corsHeaders.put("Access-Control-Allow-Headers", requestedHeaders);
93+
boolean isWildcard = WILDCARD.equals(allowOriginValue);
94+
boolean isPreflight = httpMethodName != null && PREFLIGHT_REQUEST.equalsIgnoreCase(httpMethodName);
95+
96+
HashMap<String, String> corsHeaders = new LinkedHashMap<>();
97+
corsHeaders.put("Access-Control-Allow-Origin", allowOriginValue);
98+
if (!isWildcard) {
99+
// Vary lets caches differentiate responses per Origin.
100+
corsHeaders.put("Vary", "Origin");
101+
// "*" + credentials is forbidden by the CORS spec, so credentials only when echoing a real origin.
102+
corsHeaders.put("Access-Control-Allow-Credentials", "true");
59103
}
60-
if (isPreflightRequest && requestedMethod != null && !requestedMethod.isEmpty()) {
61-
corsHeaders.put("Access-Control-Allow-Methods", requestedMethod);
104+
105+
if (isPreflight) {
106+
corsHeaders.put("Access-Control-Max-Age", CORS_MAX_AGE_SECONDS);
107+
if (requestedMethod != null && !requestedMethod.isEmpty()) {
108+
corsHeaders.put("Access-Control-Allow-Methods", requestedMethod);
109+
}
110+
if (requestedHeaders != null && !requestedHeaders.isEmpty()) {
111+
corsHeaders.put("Access-Control-Allow-Headers", requestedHeaders);
112+
}
62113
}
63114

64115
return corsHeaders;
65116
}
66117

67118
private static String getHeaderValue(String headerName, Map<String, List<String>> headers) {
119+
if (headers == null) return null;
68120
List<String> value = headers.get(headerName);
69-
if (value != null && value.size() > 0) {
70-
return value.get(0);
121+
if (value == null) {
122+
for (Map.Entry<String, List<String>> e : headers.entrySet()) {
123+
if (e.getKey() != null && headerName.equalsIgnoreCase(e.getKey())) {
124+
value = e.getValue();
125+
break;
126+
}
127+
}
71128
}
129+
if (value != null && !value.isEmpty()) return value.get(0);
72130
return null;
73131
}
74132
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.genexus.cors;
2+
3+
import org.junit.After;
4+
import org.junit.Test;
5+
6+
import java.util.Arrays;
7+
import java.util.Collections;
8+
import java.util.HashMap;
9+
import java.util.LinkedHashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.function.Supplier;
13+
14+
import static org.junit.Assert.assertEquals;
15+
import static org.junit.Assert.assertFalse;
16+
import static org.junit.Assert.assertNotNull;
17+
import static org.junit.Assert.assertNull;
18+
import static org.junit.Assert.assertTrue;
19+
20+
public class CORSHelperTest {
21+
22+
private final Supplier<String> originalSupplier = CORSHelper.allowedOriginSupplier;
23+
24+
@After
25+
public void restoreSupplier() {
26+
CORSHelper.allowedOriginSupplier = originalSupplier;
27+
}
28+
29+
private void configureAllowedOrigin(final String value) {
30+
CORSHelper.allowedOriginSupplier = new Supplier<String>() {
31+
@Override public String get() { return value; }
32+
};
33+
}
34+
35+
@Test
36+
public void corsSupportDisabledWhenNotConfigured() {
37+
configureAllowedOrigin("");
38+
assertFalse(CORSHelper.corsSupportEnabled());
39+
assertNull(CORSHelper.getCORSHeaders("GET", "https://app.example", null, null));
40+
}
41+
42+
@Test
43+
public void corsSupportDisabledWhenSupplierReturnsNull() {
44+
configureAllowedOrigin(null);
45+
assertFalse(CORSHelper.corsSupportEnabled());
46+
}
47+
48+
@Test
49+
public void corsSupportEnabledWhenConfigured() {
50+
configureAllowedOrigin("https://app.example");
51+
assertTrue(CORSHelper.corsSupportEnabled());
52+
}
53+
54+
@Test
55+
public void noHeadersWhenRequestHasNoOrigin() {
56+
configureAllowedOrigin("https://app.example");
57+
assertNull(CORSHelper.getCORSHeaders("GET", null, null, null));
58+
assertNull(CORSHelper.getCORSHeaders("GET", "", null, null));
59+
}
60+
61+
@Test
62+
public void noHeadersWhenOriginNotInAllowlist() {
63+
configureAllowedOrigin("https://app.example");
64+
assertNull(CORSHelper.getCORSHeaders("GET", "https://evil.example", null, null));
65+
}
66+
67+
@Test
68+
public void singleAllowedOriginSimpleRequest() {
69+
configureAllowedOrigin("https://app.example");
70+
HashMap<String, String> headers = CORSHelper.getCORSHeaders("GET", "https://app.example", null, null);
71+
72+
assertNotNull(headers);
73+
assertEquals("https://app.example", headers.get("Access-Control-Allow-Origin"));
74+
assertEquals("Origin", headers.get("Vary"));
75+
assertEquals("true", headers.get("Access-Control-Allow-Credentials"));
76+
assertFalse("Max-Age belongs only on preflight responses", headers.containsKey("Access-Control-Max-Age"));
77+
assertFalse(headers.containsKey("Access-Control-Allow-Methods"));
78+
assertFalse(headers.containsKey("Access-Control-Allow-Headers"));
79+
}
80+
81+
@Test
82+
public void preflightIncludesMaxAgeAndRequestedMethodAndHeaders() {
83+
configureAllowedOrigin("https://app.example");
84+
HashMap<String, String> headers = CORSHelper.getCORSHeaders(
85+
"OPTIONS", "https://app.example", "PUT", "Content-Type, X-Custom");
86+
87+
assertNotNull(headers);
88+
assertEquals("https://app.example", headers.get("Access-Control-Allow-Origin"));
89+
assertEquals("Origin", headers.get("Vary"));
90+
assertEquals("true", headers.get("Access-Control-Allow-Credentials"));
91+
assertEquals("86400", headers.get("Access-Control-Max-Age"));
92+
assertEquals("PUT", headers.get("Access-Control-Allow-Methods"));
93+
assertEquals("Content-Type, X-Custom", headers.get("Access-Control-Allow-Headers"));
94+
}
95+
96+
@Test
97+
public void wildcardOriginNeverCombinesWithCredentials() {
98+
configureAllowedOrigin("*");
99+
HashMap<String, String> headers = CORSHelper.getCORSHeaders("GET", "https://anything.example", null, null);
100+
101+
assertNotNull(headers);
102+
assertEquals("*", headers.get("Access-Control-Allow-Origin"));
103+
assertFalse("'*' must not be sent with credentials per the CORS spec",
104+
headers.containsKey("Access-Control-Allow-Credentials"));
105+
assertFalse("Vary: Origin is unnecessary when emitting '*'",
106+
headers.containsKey("Vary"));
107+
}
108+
109+
@Test
110+
public void wildcardOriginPreflightIncludesMaxAge() {
111+
configureAllowedOrigin("*");
112+
HashMap<String, String> headers = CORSHelper.getCORSHeaders(
113+
"OPTIONS", "https://anything.example", "POST", "Content-Type");
114+
115+
assertNotNull(headers);
116+
assertEquals("*", headers.get("Access-Control-Allow-Origin"));
117+
assertEquals("86400", headers.get("Access-Control-Max-Age"));
118+
assertEquals("POST", headers.get("Access-Control-Allow-Methods"));
119+
}
120+
121+
@Test
122+
public void allowlistMatchesOneOfMultiple() {
123+
configureAllowedOrigin("https://a.example, https://b.example ,https://c.example");
124+
125+
HashMap<String, String> b = CORSHelper.getCORSHeaders("GET", "https://b.example", null, null);
126+
assertNotNull(b);
127+
assertEquals("https://b.example", b.get("Access-Control-Allow-Origin"));
128+
assertEquals("Origin", b.get("Vary"));
129+
assertEquals("true", b.get("Access-Control-Allow-Credentials"));
130+
131+
assertNull(CORSHelper.getCORSHeaders("GET", "https://d.example", null, null));
132+
}
133+
134+
@Test
135+
public void mapOverloadReadsOriginAndIsCaseInsensitive() {
136+
configureAllowedOrigin("https://app.example");
137+
Map<String, List<String>> requestHeaders = new LinkedHashMap<>();
138+
requestHeaders.put("origin", Collections.singletonList("https://app.example"));
139+
requestHeaders.put("access-control-request-method", Collections.singletonList("DELETE"));
140+
requestHeaders.put("access-control-request-headers", Arrays.asList("X-A, X-B"));
141+
142+
HashMap<String, String> headers = CORSHelper.getCORSHeaders("OPTIONS", requestHeaders);
143+
assertNotNull(headers);
144+
assertEquals("https://app.example", headers.get("Access-Control-Allow-Origin"));
145+
assertEquals("DELETE", headers.get("Access-Control-Allow-Methods"));
146+
assertEquals("X-A, X-B", headers.get("Access-Control-Allow-Headers"));
147+
}
148+
149+
@Test
150+
public void mapOverloadReturnsNullWithoutOrigin() {
151+
configureAllowedOrigin("https://app.example");
152+
Map<String, List<String>> requestHeaders = new LinkedHashMap<>();
153+
requestHeaders.put("Access-Control-Request-Method", Collections.singletonList("POST"));
154+
155+
assertNull(CORSHelper.getCORSHeaders("OPTIONS", requestHeaders));
156+
}
157+
158+
@Test
159+
public void isPreflightSemantics() {
160+
assertTrue(CORSHelper.isPreflight("OPTIONS", "https://x", "GET"));
161+
assertTrue(CORSHelper.isPreflight("options", "https://x", "GET"));
162+
assertFalse(CORSHelper.isPreflight("GET", "https://x", "GET"));
163+
assertFalse(CORSHelper.isPreflight("OPTIONS", null, "GET"));
164+
assertFalse(CORSHelper.isPreflight("OPTIONS", "", "GET"));
165+
assertFalse(CORSHelper.isPreflight("OPTIONS", "https://x", null));
166+
assertFalse(CORSHelper.isPreflight("OPTIONS", "https://x", ""));
167+
assertFalse(CORSHelper.isPreflight(null, "https://x", "GET"));
168+
}
169+
170+
@Test
171+
public void nullHttpMethodDoesNotThrow() {
172+
configureAllowedOrigin("https://app.example");
173+
HashMap<String, String> headers = CORSHelper.getCORSHeaders(null, "https://app.example", null, null);
174+
assertNotNull(headers);
175+
assertEquals("https://app.example", headers.get("Access-Control-Allow-Origin"));
176+
assertFalse(headers.containsKey("Access-Control-Max-Age"));
177+
}
178+
179+
@Test
180+
public void preflightWithoutRequestedMethodOrHeadersOmitsThem() {
181+
configureAllowedOrigin("https://app.example");
182+
HashMap<String, String> headers = CORSHelper.getCORSHeaders(
183+
"OPTIONS", "https://app.example", null, null);
184+
assertNotNull(headers);
185+
assertEquals("86400", headers.get("Access-Control-Max-Age"));
186+
assertFalse(headers.containsKey("Access-Control-Allow-Methods"));
187+
assertFalse(headers.containsKey("Access-Control-Allow-Headers"));
188+
}
189+
}

wrapperjakarta/src/main/java/com/genexus/servlet/CorsFilter.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,23 @@ public void init(FilterConfig filterConfig) throws ServletException {
2222
@Override
2323
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
2424
HttpServletRequest request = (HttpServletRequest) servletRequest;
25+
HttpServletResponse response = (HttpServletResponse) servletResponse;
2526

26-
HashMap<String, String> corsHeaders = CORSHelper.getCORSHeaders(request.getMethod(), request.getHeader(CORSHelper.REQUEST_METHOD_HEADER_NAME), request.getHeader(CORSHelper.REQUEST_HEADERS_HEADER_NAME));
27+
String origin = request.getHeader(CORSHelper.ORIGIN_HEADER_NAME);
28+
String requestedMethod = request.getHeader(CORSHelper.REQUEST_METHOD_HEADER_NAME);
29+
String requestedHeaders = request.getHeader(CORSHelper.REQUEST_HEADERS_HEADER_NAME);
30+
31+
HashMap<String, String> corsHeaders = CORSHelper.getCORSHeaders(request.getMethod(), origin, requestedMethod, requestedHeaders);
2732
if (corsHeaders != null) {
28-
HttpServletResponse response = (HttpServletResponse) servletResponse;
2933
for (String headerName : corsHeaders.keySet()) {
3034
if (!response.containsHeader(headerName)) {
3135
response.setHeader(headerName, corsHeaders.get(headerName));
3236
}
3337
}
38+
if (CORSHelper.isPreflight(request.getMethod(), origin, requestedMethod)) {
39+
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
40+
return;
41+
}
3442
}
3543
filterChain.doFilter(servletRequest, servletResponse);
3644
}

0 commit comments

Comments
 (0)