diff --git a/changes.md b/changes.md index 1590e7fbf2..393076b2d0 100644 --- a/changes.md +++ b/changes.md @@ -2,10 +2,14 @@ Changes log =========== - 2.6.2 (??-??-2026) + - Misc + - Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate)`) to accept origins that are not listed, for example any sub-domain. The response always contains the exact request origin; it has no effect while the allowed origins contain `*`. Issue #1611. + - CorsService and CorsFilter now log a warning when `*` is allowed as origin together with credentials (any web site could then send credentialed requests), and their Javadoc references the Fetch standard instead of the W3C CORS draft. Issue #1611. - Bugs fixed - Missing throw in JsonConverter.java. Issue #729. - Type mismatch on container access in MethodAnnotationInfo. Issue #1278. - Boolean canAppend in ZipClientHelper can only be false. Issue #1279. + - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. - 2.6.1 (30-09-2026) - Bugs fixed diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java index 62ea3270a4..f7cca25631 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java @@ -20,11 +20,18 @@ import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.function.Predicate; /** * Filter that helps support CORS requests. This filter lets the target * resources specify the allowed methods. * + *

Security note: the Fetch standard does not accept the wildcard {@code *} in + * {@code Access-Control-Allow-Origin} for requests with credentials, so when credentials are allowed + * the request origin is echoed back. Combining {@code *} in the allowed origins with allowed + * credentials therefore lets any web site send credentialed requests: list the allowed origins + * explicitly instead. + * * Example: * *

@@ -60,6 +67,9 @@ public class CorsFilter extends Filter {
 	/** The value of 'Access-Control-Allow-Origin' header. Default is '*'. */
 	private Set allowedOrigins = SetUtils.newHashSet("*");
 
+	/** Optional additional rule to accept an origin that is not listed in {@link #allowedOrigins}. */
+	private Predicate allowedOriginMatcher = null;
+
 	/** Helper for generating CORS response. */
 	private CorsResponseHelper corsResponseHelper;
 
@@ -183,6 +193,12 @@ protected CorsResponseHelper getCorsResponseHelper() {
 			corsResponseHelper.setAllowedHeaders(allowedHeaders);
 			corsResponseHelper.setExposedHeaders(exposedHeaders);
 			corsResponseHelper.setMaxAge(maxAge);
+			corsResponseHelper.setAllowedOriginMatcher(allowedOriginMatcher);
+			if (allowedCredentials && allowedOrigins != null && allowedOrigins.contains("*")) {
+				getLogger().warning(
+						"CORS: allowed origins contain '*' and credentials are allowed, so any web site "
+								+ "can send credentialed requests. List the allowed origins explicitly.");
+			}
 		}
 		return corsResponseHelper;
 	}
@@ -281,6 +297,32 @@ public CorsFilter setAllowedHeaders(Set allowedHeaders) {
 		return this;
 	}
 
+	/**
+	 * Returns the optional rule used to accept origins that are not listed in the allowed origins.
+	 *
+	 * @return The origin matcher, or null.
+	 * @since 2.6.2
+	 */
+	public Predicate getAllowedOriginMatcher() {
+		return allowedOriginMatcher;
+	}
+
+	/**
+	 * Sets an optional rule to accept origins that are not listed in the allowed origins, for
+	 * example {@code origin -> origin.endsWith(".example.com")}. The response always contains the
+	 * exact request origin. The set of allowed origins must not contain "*", otherwise every origin
+	 * is accepted. By default no matcher is used.
+	 *
+	 * @param allowedOriginMatcher The origin matcher, or null for none.
+	 * @return Itself for chaining methods calls.
+	 * @since 2.6.2
+	 */
+	public CorsFilter setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+		this.allowedOriginMatcher = allowedOriginMatcher;
+		this.corsResponseHelper = null;
+		return this;
+	}
+
 	/**
 	 * Sets the value of 'Access-Control-Allow-Origin' header.
 	 * 
diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
index e293396a8f..f3d852d1fc 100644
--- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
+++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
@@ -12,12 +12,14 @@
 import org.restlet.Context;
 import org.restlet.Request;
 import org.restlet.Response;
+import org.restlet.data.Dimension;
 import org.restlet.data.Method;
 import org.restlet.data.Status;
 import org.restlet.engine.util.SetUtils;
 
 import java.util.HashSet;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.logging.Logger;
 
 /**
@@ -26,8 +28,9 @@
  * GET and POST. Any other methods should send a preflight request with the
  * method OPTIONS.
  * 
- * @see W3C CORS Specification
- * @see Simple methods
+ * @see Fetch Standard, CORS protocol
+ * @see Fetch Standard, CORS protocol and HTTP caches
+ * @see RFC 9110, Vary
  * 
  * @author Manuel Boillod
  */
@@ -56,6 +59,12 @@ public class CorsResponseHelper {
 	/** The value of 'Access-Control-Allow-Origin' header. Default is '*'. */
 	public Set allowedOrigins = SetUtils.newHashSet("*");
 
+	/**
+	 * Optional additional rule to accept an origin that is not listed in {@link #allowedOrigins}.
+	 * Default is null.
+	 */
+	public Predicate allowedOriginMatcher = null;
+
 	/** The value of 'Access-Control-Expose-Headers' response header. */
 	public Set exposedHeaders = null;
 
@@ -73,6 +82,14 @@ public class CorsResponseHelper {
 	 */
 	public void addCorsResponseHeaders(Request request, Response response) {
 
+		// When the answer depends on the Origin request header (allow-list or credentials),
+		// caches must key on it, including for requests without Origin, whose response lacks
+		// CORS headers (Fetch standard, "CORS protocol and HTTP caches"; RFC 9110, 12.5.5).
+		// A static "*" must not use Vary.
+		if (allowedCredentials || !allowedOrigins.contains("*")) {
+			response.getDimensions().add(Dimension.ORIGIN);
+		}
+
 		String origin = request.getHeaders().getFirstValue("Origin", true);
 
 		if (origin == null) {
@@ -84,7 +101,7 @@ public void addCorsResponseHeaders(Request request, Response response) {
 		// Header 'Allow' is not relevant in CORS request.
 		response.getAllowedMethods().clear();
 
-		if (!allowedOrigins.contains("*") && !allowedOrigins.contains(origin)) {
+		if (!isOriginAllowed(origin)) {
 			// Origin not allowed
 			LOGGER.fine("Origin " + origin + " not allowed for CORS request");
 			return;
@@ -313,6 +330,42 @@ public void setAllowedHeaders(Set allowedHeaders) {
 		this.allowedHeaders = allowedHeaders;
 	}
 
+	/**
+	 * Returns the optional rule used to accept origins that are not listed in the allowed origins.
+	 *
+	 * @return The origin matcher, or null.
+	 * @since 2.6.2
+	 */
+	public Predicate getAllowedOriginMatcher() {
+		return allowedOriginMatcher;
+	}
+
+	/**
+	 * Indicates if the given request origin is allowed: "*", a listed origin, or accepted by the
+	 * origin matcher. The comparison is exact and case-sensitive, as in the Fetch standard.
+	 *
+	 * @param origin The value of the 'Origin' request header.
+	 * @return True if the origin is allowed.
+	 */
+	private boolean isOriginAllowed(String origin) {
+		return allowedOrigins.contains("*")
+				|| allowedOrigins.contains(origin)
+				|| (allowedOriginMatcher != null && allowedOriginMatcher.test(origin));
+	}
+
+	/**
+	 * Sets an optional rule to accept origins that are not listed in the allowed origins, for
+	 * example {@code origin -> origin.endsWith(".example.com")}. The response always contains the
+	 * exact request origin. The set of allowed origins must not contain "*", otherwise every origin
+	 * is accepted. By default no matcher is used.
+	 *
+	 * @param allowedOriginMatcher The origin matcher, or null for none.
+	 * @since 2.6.2
+	 */
+	public void setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+		this.allowedOriginMatcher = allowedOriginMatcher;
+	}
+
 	/**
 	 * Sets the value of 'Access-Control-Allow-Origin' header.
 	 * 
diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/service/CorsService.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/service/CorsService.java
index 04bad797e9..d1915d50fc 100644
--- a/org.restlet.java/org.restlet/src/main/java/org/restlet/service/CorsService.java
+++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/service/CorsService.java
@@ -18,11 +18,18 @@
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.function.Predicate;
 
 /**
  * Application service that adds support of CORS. This service lets the target
  * resource specifies the allowed methods.
  * 
+ * 

Security note: the Fetch standard does not accept the wildcard {@code *} in + * {@code Access-Control-Allow-Origin} for requests with credentials, so when credentials are allowed + * the request origin is echoed back. Combining {@code *} in the allowed origins with allowed + * credentials therefore lets any web site send credentialed requests: list the allowed origins + * explicitly instead. + * * Example: * *

@@ -49,6 +56,9 @@ public class CorsService extends Service {
 	/** The value of 'Access-Control-Allow-Origin' header. Default is '*'. */
 	private Set allowedOrigins = SetUtils.newHashSet("*");
 
+	/** Optional additional rule to accept an origin that is not listed in the allowed origins. */
+	private Predicate allowedOriginMatcher = null;
+
 	/**
 	 * If true, copies the value of 'Access-Control-Request-Headers' request header
 	 * into the 'Access-Control-Allow-Headers' response header. If false, use
@@ -99,6 +109,7 @@ public CorsService(boolean enabled) {
 	@Override
 	public Filter createInboundFilter(Context context) {
 		return new CorsFilter().setAllowedCredentials(allowedCredentials).setAllowedOrigins(allowedOrigins)
+				.setAllowedOriginMatcher(allowedOriginMatcher)
 				.setAllowingAllRequestedHeaders(allowingAllRequestedHeaders).setAllowedHeaders(allowedHeaders)
 				.setExposedHeaders(exposedHeaders).setSkippingResourceForCorsOptions(skippingResourceForCorsOptions)
 				.setDefaultAllowedMethods(getDefaultAllowedMethods()).setMaxAge(maxAge);
@@ -221,6 +232,29 @@ public void setAllowedHeaders(Set allowedHeaders) {
 		this.allowedHeaders = allowedHeaders;
 	}
 
+	/**
+	 * Returns the optional rule used to accept origins that are not listed in the allowed origins.
+	 *
+	 * @return The origin matcher, or null.
+	 * @since 2.6.2
+	 */
+	public Predicate getAllowedOriginMatcher() {
+		return allowedOriginMatcher;
+	}
+
+	/**
+	 * Sets an optional rule to accept origins that are not listed in the allowed origins, for
+	 * example {@code origin -> origin.endsWith(".example.com")}. The response always contains the
+	 * exact request origin. The set of allowed origins must not contain "*", otherwise every origin
+	 * is accepted. By default no matcher is used.
+	 *
+	 * @param allowedOriginMatcher The origin matcher, or null for none.
+	 * @since 2.6.2
+	 */
+	public void setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+		this.allowedOriginMatcher = allowedOriginMatcher;
+	}
+
 	/**
 	 * Sets the value of 'Access-Control-Allow-Origin' header.
 	 * 
diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
index 25e3512a58..b2cd8c3411 100644
--- a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
+++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
@@ -163,6 +163,167 @@ public void testOption_requestGet_withAuthenticationAllowed() {
         assertIsEmpty(response.getAccessControlExposeHeaders());
     }
 
+    // VARY: ORIGIN (Fetch standard, "CORS protocol and HTTP caches"; RFC 9110 section 12.5.5)
+
+    /** Static "*" without credentials: same answer for everybody, Vary must not be used. */
+    @Test
+    void testGet_staticWildcard_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "localhost");
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOption_requestGet_staticWildcard_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.OPTIONS);
+        request.getHeaders().set("Origin", "localhost");
+        request.setAccessControlRequestMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_staticWildcard_withoutOrigin_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_withAuthenticationAllowed_varyOrigin() {
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "localhost");
+        Response response = corsFilter.handle(request);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOption_requestGet_withAuthenticationAllowed_varyOrigin() {
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.OPTIONS);
+        request.getHeaders().set("Origin", "localhost");
+        request.setAccessControlRequestMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_allowList_allowedOrigin_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://allowed.example");
+        Response response = corsFilter.handle(request);
+        assertEquals("http://allowed.example", response.getAccessControlAllowOrigin());
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_allowList_originNotAllowed_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://other.example");
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        // A cache must not reuse this answer for the allowed origin
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    /**
+     * Without Origin, the response lacks CORS headers: a cache must not serve it later to a CORS
+     * request.
+     */
+    @Test
+    void testGet_allowList_withoutOrigin_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_withAuthenticationAllowed_withoutOrigin_varyOrigin() {
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    // ORIGIN MATCHER (opt-in)
+
+    @Test
+    void testOriginMatcher_acceptsMatchingOrigin_echoesExactOrigin() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://app.example.com");
+        Response response = corsFilter.handle(request);
+        assertEquals("https://app.example.com", response.getAccessControlAllowOrigin());
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOriginMatcher_rejectsOtherOrigin() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://evil.example.org");
+        assertNoCorsHeaders(corsFilter.handle(request));
+    }
+
+    @Test
+    void testOriginMatcher_withCredentials() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://app.example.com");
+        Response response = corsFilter.handle(request);
+        assertEquals("https://app.example.com", response.getAccessControlAllowOrigin());
+        assertEquals(Boolean.TRUE, response.getAccessControlAllowCredentials());
+    }
+
+    @Test
+    void testOriginMatcher_changedAfterFirstRequest_isApplied() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        Request first = new Request();
+        first.setMethod(Method.GET);
+        first.getHeaders().set("Origin", "https://app.example.com");
+        assertNoCorsHeaders(corsFilter.handle(first));
+
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+        Request second = new Request();
+        second.setMethod(Method.GET);
+        second.getHeaders().set("Origin", "https://app.example.com");
+        assertEquals(
+                "https://app.example.com",
+                corsFilter.handle(second).getAccessControlAllowOrigin());
+    }
+
     private void assertIsEmpty(Collection collection) {
         assertNotNull(collection);
         assertTrue(collection.isEmpty());