diff --git a/changes.md b/changes.md index f2967bf95c..1125222843 100644 --- a/changes.md +++ b/changes.md @@ -7,11 +7,14 @@ Changes log - Upgraded Jackson library to version 2.22.4. - Upgraded org.json library to version 20260814. - Deprecated the legacy RFC 2109/2965 cookie API (cookie version, comment and related constructors), to be removed in Restlet 3.0 in favor of RFC 6265. + - 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 - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. - When GET is an allowed method of a server resource, HEAD is now also listed (e.g. in the Allow header of a 405 response). Issue #266. - The Max-Age and Path attributes of a version 1 cookie are no longer quoted in the Set-Cookie header, as browsers ignore them otherwise. Issue #1334. - CookieAuthenticator no longer lets the next restlet overwrite the redirection sent after a login. Issue #554. + - 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.7.0 (30-09-2026) - Misc diff --git a/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java b/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java index 78929d2ada..bd98649c58 100644 --- a/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java +++ b/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java @@ -11,6 +11,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import org.restlet.Context; import org.restlet.Request; import org.restlet.Response; @@ -23,6 +24,12 @@ * 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: * *

@@ -56,6 +63,11 @@ public class CorsFilter extends Filter {
     /** The value of the '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;
 
@@ -174,6 +186,13 @@ 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;
     }
@@ -269,6 +288,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.7.1
+     */
+    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.7.1
+     */
+    public CorsFilter setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+        this.allowedOriginMatcher = allowedOriginMatcher;
+        this.corsResponseHelper = null;
+        return this;
+    }
+
     /**
      * Sets the value of the 'Access-Control-Allow-Origin' header.
      *
diff --git a/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java b/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
index 381c800b05..d08da55ae5 100644
--- a/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
+++ b/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
@@ -10,10 +10,12 @@
 
 import java.util.HashSet;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.logging.Logger;
 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;
@@ -23,8 +25,11 @@
  * The CORS specification defines a subset of methods qualified as simple HEAD, 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
  */
 public class CorsResponseHelper {
@@ -50,6 +55,12 @@ public class CorsResponseHelper {
     /** The value of the '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}.
+     * Default is null.
+     */
+    private Predicate allowedOriginMatcher = null;
+
     /** The value of 'Access-Control-Expose-Headers' response header. */
     private Set exposedHeaders = null;
 
@@ -66,6 +77,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) {
@@ -77,7 +96,7 @@ public void addCorsResponseHeaders(Request request, Response response) {
         // Header 'Allow' is not relevant in a CORS request.
         response.getAllowedMethods().clear();
 
-        if (!allowedOrigins.contains("*") && !allowedOrigins.contains(origin)) {
+        if (!isOriginAllowed(origin)) {
             // Origin isn't allowed
             LOGGER.fine(() -> "Origin " + origin + " not allowed for CORS request");
             return;
@@ -301,6 +320,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.7.1
+     */
+    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.7.1
+     */
+    public void setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+        this.allowedOriginMatcher = allowedOriginMatcher;
+    }
+
     /**
      * Sets the value of the 'Access-Control-Allow-Origin' header.
      *
diff --git a/org.restlet/src/main/java/org/restlet/service/CorsService.java b/org.restlet/src/main/java/org/restlet/service/CorsService.java
index 766f8aabfe..ae6927cd55 100644
--- a/org.restlet/src/main/java/org/restlet/service/CorsService.java
+++ b/org.restlet/src/main/java/org/restlet/service/CorsService.java
@@ -11,6 +11,7 @@
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.function.Predicate;
 import org.restlet.Context;
 import org.restlet.data.Method;
 import org.restlet.engine.application.CorsFilter;
@@ -21,6 +22,12 @@
  * Application service that adds support of CORS. This service lets the target resource 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: * *

@@ -45,6 +52,9 @@ public class CorsService extends Service {
     /** The value of the '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 {@link #allowedHeaders}.
@@ -94,6 +104,7 @@ public CorsService(boolean enabled) {
     @Override
     public Filter createInboundFilter(Context context) {
         return new CorsFilter()
+                .setAllowedOriginMatcher(allowedOriginMatcher)
                 .setAllowedCredentials(allowedCredentials)
                 .setAllowedOrigins(allowedOrigins)
                 .setAllowingAllRequestedHeaders(allowingAllRequestedHeaders)
@@ -216,6 +227,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.7.1
+     */
+    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.7.1
+     */
+    public void setAllowedOriginMatcher(Predicate allowedOriginMatcher) {
+        this.allowedOriginMatcher = allowedOriginMatcher;
+    }
+
     /**
      * Sets the value of the 'Access-Control-Allow-Origin' header.
      *
diff --git a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
index 9bde9e770e..4bcb3d0a67 100644
--- a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
@@ -10,6 +10,7 @@
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -179,6 +180,166 @@ 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());
diff --git a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
index 1032ab801e..7b7ec20dbd 100644
--- a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
@@ -11,6 +11,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Set;
@@ -80,4 +81,16 @@ void createInboundFilter_returnsConfiguredCorsFilter() {
 
         assertInstanceOf(CorsFilter.class, filter);
     }
+
+    @Test
+    void createInboundFilter_transmitsOriginMatcher() {
+        CorsService service = new CorsService();
+        service.setAllowedOrigins(Set.of());
+        service.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        CorsFilter filter = (CorsFilter) service.createInboundFilter(new Context());
+
+        assertNotNull(filter.getAllowedOriginMatcher());
+        assertTrue(filter.getAllowedOriginMatcher().test("https://a.example.com"));
+    }
 }