Skip to content

Commit ad11362

Browse files
committed
feat: Add CORS preflight handler
1 parent 4b5d642 commit ad11362

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

src/main/java/com/retailsvc/http/Handlers.java

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import static com.retailsvc.http.spec.HttpMethod.GET;
44
import static com.retailsvc.http.spec.HttpMethod.HEAD;
5+
import static com.retailsvc.http.spec.HttpMethod.OPTIONS;
56
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
67
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
8+
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
79
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
10+
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
811
import static java.net.HttpURLConnection.HTTP_OK;
912
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
1013
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -13,10 +16,15 @@
1316
import com.retailsvc.http.internal.ProblemDetail;
1417
import com.retailsvc.http.internal.ProblemDetailRenderer;
1518
import com.retailsvc.http.internal.ResourceSource;
19+
import com.retailsvc.http.spec.HttpMethod;
1620
import java.io.InputStream;
1721
import java.nio.file.Path;
22+
import java.time.Duration;
1823
import java.util.List;
24+
import java.util.Locale;
1925
import java.util.Objects;
26+
import java.util.Set;
27+
import java.util.function.Predicate;
2028
import java.util.function.Supplier;
2129
import java.util.stream.Collectors;
2230
import org.slf4j.Logger;
@@ -54,6 +62,126 @@ public static ResponseDecorator securityHeadersDecorator() {
5462
};
5563
}
5664

65+
/**
66+
* Returns a {@link RequestHandler} that answers CORS preflight {@code OPTIONS} requests for any
67+
* path the caller wires it under (typically via {@code
68+
* OpenApiServer.builder().extraRoute("/api/*", Handlers.corsPreflightHandler(...))}).
69+
*
70+
* <p>Requests are validated in order: origin against {@code allowedOrigins} (exact match), {@code
71+
* Access-Control-Request-Method} against {@code allowedMethods}, and each header in {@code
72+
* Access-Control-Request-Headers} against {@code allowedHeaders} (case-insensitive). A non-{@code
73+
* OPTIONS} request yields {@code 405} with {@code Allow: OPTIONS}; a missing {@code Origin} or
74+
* {@code Access-Control-Request-Method} header yields {@code 400}; any disallowed origin / method
75+
* / header yields {@code 403} with no CORS headers (the browser then blocks the request).
76+
*
77+
* <p>On success the response is {@code 204 No Content} with {@code Access-Control-Allow-Origin}
78+
* echoing the request's {@code Origin}, the configured method and header allowlists, and {@code
79+
* Vary: Origin} so caches segment by origin. {@code Access-Control-Allow-Credentials} and {@code
80+
* Access-Control-Max-Age} are emitted only when enabled.
81+
*
82+
* @param allowedOrigins exact-match origin allowlist; never {@code null}
83+
* @param allowedMethods non-empty list of methods to advertise in {@code Allow-Methods}
84+
* @param allowedHeaders header allowlist (matched case-insensitively); may be empty (then {@code
85+
* Access-Control-Allow-Headers} is omitted)
86+
* @param allowCredentials whether to emit {@code Access-Control-Allow-Credentials: true}
87+
* @param maxAge {@code Access-Control-Max-Age} value; {@code null} omits the header
88+
*/
89+
public static RequestHandler corsPreflightHandler(
90+
List<String> allowedOrigins,
91+
List<HttpMethod> allowedMethods,
92+
List<String> allowedHeaders,
93+
boolean allowCredentials,
94+
Duration maxAge) {
95+
Objects.requireNonNull(allowedOrigins, "allowedOrigins must not be null");
96+
Set<String> origins = Set.copyOf(allowedOrigins);
97+
return corsPreflightHandler(
98+
origins::contains, allowedMethods, allowedHeaders, allowCredentials, maxAge);
99+
}
100+
101+
/**
102+
* Predicate-based overload of {@link #corsPreflightHandler(List, List, List, boolean, Duration)}
103+
* for callers that need dynamic origin policy (regex, suffix match, config lookup).
104+
*/
105+
public static RequestHandler corsPreflightHandler(
106+
Predicate<String> originAllowed,
107+
List<HttpMethod> allowedMethods,
108+
List<String> allowedHeaders,
109+
boolean allowCredentials,
110+
Duration maxAge) {
111+
Objects.requireNonNull(originAllowed, "originAllowed must not be null");
112+
Objects.requireNonNull(allowedMethods, "allowedMethods must not be null");
113+
Objects.requireNonNull(allowedHeaders, "allowedHeaders must not be null");
114+
if (allowedMethods.isEmpty()) {
115+
throw new IllegalArgumentException("allowedMethods must not be empty");
116+
}
117+
if (maxAge != null && (maxAge.isNegative() || maxAge.getSeconds() > Integer.MAX_VALUE)) {
118+
throw new IllegalArgumentException(
119+
"maxAge must be non-negative and fit in an int number of seconds, got " + maxAge);
120+
}
121+
122+
String allowMethodsHeader =
123+
allowedMethods.stream().map(Enum::name).collect(Collectors.joining(", "));
124+
String allowHeadersHeader = String.join(", ", allowedHeaders);
125+
Set<String> headerAllowlistLower =
126+
allowedHeaders.stream()
127+
.map(h -> h.toLowerCase(Locale.ROOT))
128+
.collect(Collectors.toUnmodifiableSet());
129+
String maxAgeHeader = maxAge == null ? null : Long.toString(maxAge.getSeconds());
130+
131+
return req -> {
132+
if (req.method() != OPTIONS) {
133+
return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "OPTIONS");
134+
}
135+
String origin = req.header("Origin").orElse(null);
136+
if (origin == null) {
137+
throw new BadRequestException("CORS preflight is missing the Origin header");
138+
}
139+
String requestMethod = req.header("Access-Control-Request-Method").orElse(null);
140+
if (requestMethod == null) {
141+
throw new BadRequestException(
142+
"CORS preflight is missing the Access-Control-Request-Method header");
143+
}
144+
if (!originAllowed.test(origin)) {
145+
return Response.status(HTTP_FORBIDDEN);
146+
}
147+
HttpMethod parsedMethod;
148+
try {
149+
parsedMethod = HttpMethod.parse(requestMethod);
150+
} catch (IllegalArgumentException e) {
151+
return Response.status(HTTP_FORBIDDEN);
152+
}
153+
if (!allowedMethods.contains(parsedMethod)) {
154+
return Response.status(HTTP_FORBIDDEN);
155+
}
156+
String requestedHeaders = req.header("Access-Control-Request-Headers").orElse("");
157+
for (String raw : requestedHeaders.split(",")) {
158+
String h = raw.trim().toLowerCase(Locale.ROOT);
159+
if (h.isEmpty()) {
160+
continue;
161+
}
162+
if (!headerAllowlistLower.contains(h)) {
163+
return Response.status(HTTP_FORBIDDEN);
164+
}
165+
}
166+
167+
Response resp =
168+
Response.status(HTTP_NO_CONTENT)
169+
.withHeader("Access-Control-Allow-Origin", origin)
170+
.withHeader("Access-Control-Allow-Methods", allowMethodsHeader)
171+
.withHeader("Vary", "Origin");
172+
if (!allowedHeaders.isEmpty()) {
173+
resp = resp.withHeader("Access-Control-Allow-Headers", allowHeadersHeader);
174+
}
175+
if (allowCredentials) {
176+
resp = resp.withHeader("Access-Control-Allow-Credentials", "true");
177+
}
178+
if (maxAgeHeader != null) {
179+
resp = resp.withHeader("Access-Control-Max-Age", maxAgeHeader);
180+
}
181+
return resp;
182+
};
183+
}
184+
57185
public static ExceptionHandler defaultExceptionHandler() {
58186
return t ->
59187
switch (t) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.retailsvc.http;
2+
3+
import static com.retailsvc.http.spec.HttpMethod.DELETE;
4+
import static com.retailsvc.http.spec.HttpMethod.GET;
5+
import static com.retailsvc.http.spec.HttpMethod.OPTIONS;
6+
import static com.retailsvc.http.spec.HttpMethod.POST;
7+
import static com.retailsvc.http.spec.HttpMethod.PUT;
8+
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
import com.retailsvc.http.spec.HttpMethod;
12+
import java.time.Duration;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.function.UnaryOperator;
16+
import org.junit.jupiter.api.Test;
17+
18+
class CorsPreflightHandlerTest {
19+
20+
private static final List<HttpMethod> METHODS = List.of(GET, POST, PUT, DELETE);
21+
private static final List<String> HEADERS = List.of("content-type", "authorization");
22+
private static final List<String> ORIGINS = List.of("https://app.example.com");
23+
24+
private static Request preflight(String origin, String requestMethod, String requestHeaders) {
25+
UnaryOperator<String> lookup =
26+
name ->
27+
switch (name.toLowerCase(java.util.Locale.ROOT)) {
28+
case "origin" -> origin;
29+
case "access-control-request-method" -> requestMethod;
30+
case "access-control-request-headers" -> requestHeaders;
31+
default -> null;
32+
};
33+
return new Request(new byte[0], null, null, null, Map.of(), null, lookup, Map.of(), OPTIONS);
34+
}
35+
36+
private static Request bare(HttpMethod method) {
37+
return new Request(new byte[0], null, null, null, Map.of(), null, n -> null, Map.of(), method);
38+
}
39+
40+
@Test
41+
void corsPreflightHandlerReturns204WithExpectedHeadersOnValidPreflight() {
42+
RequestHandler handler =
43+
Handlers.corsPreflightHandler(ORIGINS, METHODS, HEADERS, true, Duration.ofMinutes(10));
44+
45+
Response resp =
46+
handler.handle(preflight("https://app.example.com", "POST", "content-type, authorization"));
47+
48+
assertThat(resp.status()).isEqualTo(HTTP_NO_CONTENT);
49+
assertThat(resp.body()).isNull();
50+
assertThat(resp.headers())
51+
.containsEntry("Access-Control-Allow-Origin", "https://app.example.com")
52+
.containsEntry("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
53+
.containsEntry("Access-Control-Allow-Headers", "content-type, authorization")
54+
.containsEntry("Access-Control-Allow-Credentials", "true")
55+
.containsEntry("Access-Control-Max-Age", "600")
56+
.containsEntry("Vary", "Origin");
57+
}
58+
}

0 commit comments

Comments
 (0)