You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@helix.apache.org by jx...@apache.org on 2026/06/23 21:46:33 UTC

(helix) branch master updated: Improve CORS filter making it configurable

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/helix.git


The following commit(s) were added to refs/heads/master by this push:
     new becac6891 Improve CORS filter making it configurable
becac6891 is described below

commit becac68913a25b98ffa36454668487c12d425f05
Author: Junkai Xue <jx...@apache.org>
AuthorDate: Tue Jun 23 14:45:52 2026 -0700

    Improve CORS filter making it configurable
---
 .../helix/rest/server/filters/CORSFilter.java      |  84 ++++++++++--
 .../helix/rest/server/filters/TestCORSFilter.java  | 142 +++++++++++++++++++++
 2 files changed, 213 insertions(+), 13 deletions(-)

diff --git a/helix-rest/src/main/java/org/apache/helix/rest/server/filters/CORSFilter.java b/helix-rest/src/main/java/org/apache/helix/rest/server/filters/CORSFilter.java
index dac0ea133..48c8d7660 100644
--- a/helix-rest/src/main/java/org/apache/helix/rest/server/filters/CORSFilter.java
+++ b/helix-rest/src/main/java/org/apache/helix/rest/server/filters/CORSFilter.java
@@ -20,6 +20,11 @@ package org.apache.helix.rest.server.filters;
  */
 
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
 import javax.ws.rs.container.ContainerRequestContext;
 import javax.ws.rs.container.ContainerRequestFilter;
 import javax.ws.rs.container.ContainerResponseContext;
@@ -30,24 +35,65 @@ import javax.ws.rs.ext.Provider;
 @Provider
 public class CORSFilter implements ContainerRequestFilter, ContainerResponseFilter {
 
+  /**
+   * Comma-separated list of origins allowed to make cross-origin requests. A single {@code "*"}
+   * enables anonymous wildcard access (without credentials). When unset, no cross-origin requests
+   * are permitted.
+   */
+  public static final String ALLOWED_ORIGINS_PROPERTY = "cors.allowed.origins";
+
+  private static final String WILDCARD = "*";
+  // Helix REST only exposes these HTTP methods; do not reflect arbitrary requested methods.
+  private static final String ALLOWED_METHODS = "GET, POST, PUT, DELETE, OPTIONS, HEAD";
+
+  private final Set<String> _allowedOrigins;
+  private final boolean _allowAnyOrigin;
+
+  public CORSFilter() {
+    this(System.getProperty(ALLOWED_ORIGINS_PROPERTY));
+  }
+
+  CORSFilter(String allowedOriginsConfig) {
+    Set<String> origins = new HashSet<>();
+    if (allowedOriginsConfig != null) {
+      origins = Arrays.stream(allowedOriginsConfig.split(","))
+          .map(String::trim)
+          .filter(origin -> !origin.isEmpty())
+          .collect(Collectors.toSet());
+    }
+    _allowAnyOrigin = origins.contains(WILDCARD);
+    _allowedOrigins = Collections.unmodifiableSet(origins);
+  }
+
+  private boolean isAllowedOrigin(String origin) {
+    return origin != null && (_allowAnyOrigin || _allowedOrigins.contains(origin));
+  }
+
   @Override
   public void filter(ContainerRequestContext request) throws IOException {
     // handle preflight
     if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
       Response.ResponseBuilder builder = Response.ok();
+      String origin = request.getHeaderString("Origin");
 
-      // NOTE: Allow whichever HTTP Methods requested in the incoming request because the cluster
-      // administrator must be able to trigger such operations
-      // Helix REST uses GET, PUT, DELETE, POST
-      String requestMethods = request.getHeaderString("Access-Control-Request-Method");
-      if (requestMethods != null) {
-        builder.header("Access-Control-Allow-Methods", requestMethods);
-      }
+      if (isAllowedOrigin(origin)) {
+        if (_allowAnyOrigin && !_allowedOrigins.contains(origin)) {
+          // Wildcard, anonymous access only: never advertise credentials with "*".
+          builder.header("Access-Control-Allow-Origin", WILDCARD);
+        } else {
+          // Echo the specific allowed origin and permit credentials.
+          builder.header("Access-Control-Allow-Origin", origin);
+          builder.header("Access-Control-Allow-Credentials", "true");
+          builder.header("Vary", "Origin");
+        }
+
+        builder.header("Access-Control-Allow-Methods", ALLOWED_METHODS);
 
-      // NOTE: All headers must be allowed for preflight
-      String allowHeaders = request.getHeaderString("Access-Control-Request-Headers");
-      if (allowHeaders != null) {
-        builder.header("Access-Control-Allow-Headers", allowHeaders);
+        // Reflect requested headers only for an already-validated allowed origin.
+        String allowHeaders = request.getHeaderString("Access-Control-Request-Headers");
+        if (allowHeaders != null) {
+          builder.header("Access-Control-Allow-Headers", allowHeaders);
+        }
       }
 
       request.abortWith(builder.build());
@@ -58,7 +104,19 @@ public class CORSFilter implements ContainerRequestFilter, ContainerResponseFilt
   public void filter(ContainerRequestContext request,
         ContainerResponseContext response) throws IOException {
     // handle origin
-    response.getHeaders().putSingle("Access-Control-Allow-Origin", "*");
-    response.getHeaders().putSingle("Access-Control-Allow-Credentials", "true");
+    String origin = request.getHeaderString("Origin");
+    if (!isAllowedOrigin(origin)) {
+      return;
+    }
+
+    if (_allowAnyOrigin && !_allowedOrigins.contains(origin)) {
+      // Wildcard, anonymous access only: never advertise credentials with "*".
+      response.getHeaders().putSingle("Access-Control-Allow-Origin", WILDCARD);
+    } else {
+      // Echo the specific allowed origin and permit credentials.
+      response.getHeaders().putSingle("Access-Control-Allow-Origin", origin);
+      response.getHeaders().putSingle("Access-Control-Allow-Credentials", "true");
+      response.getHeaders().add("Vary", "Origin");
+    }
   }
 }
diff --git a/helix-rest/src/test/java/org/apache/helix/rest/server/filters/TestCORSFilter.java b/helix-rest/src/test/java/org/apache/helix/rest/server/filters/TestCORSFilter.java
new file mode 100644
index 000000000..e3b690d2d
--- /dev/null
+++ b/helix-rest/src/test/java/org/apache/helix/rest/server/filters/TestCORSFilter.java
@@ -0,0 +1,142 @@
+package org.apache.helix.rest.server.filters;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import java.io.IOException;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.mockito.ArgumentCaptor;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TestCORSFilter {
+  private static final String ALLOWED_ORIGIN = "https://admin.example.com";
+  private static final String DISALLOWED_ORIGIN = "https://evil.example.com";
+
+  @Test
+  public void testResponseAllowedOriginEchoesOriginAndCredentials() throws IOException {
+    CORSFilter filter = new CORSFilter(ALLOWED_ORIGIN);
+    ContainerRequestContext request = mockRequest("GET", ALLOWED_ORIGIN);
+    MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
+    ContainerResponseContext response = mock(ContainerResponseContext.class);
+    when(response.getHeaders()).thenReturn(headers);
+
+    filter.filter(request, response);
+
+    Assert.assertEquals(headers.getFirst("Access-Control-Allow-Origin"), ALLOWED_ORIGIN);
+    Assert.assertEquals(headers.getFirst("Access-Control-Allow-Credentials"), "true");
+    Assert.assertTrue(headers.get("Vary").contains("Origin"));
+  }
+
+  @Test
+  public void testResponseDisallowedOriginAddsNoHeaders() throws IOException {
+    CORSFilter filter = new CORSFilter(ALLOWED_ORIGIN);
+    ContainerRequestContext request = mockRequest("GET", DISALLOWED_ORIGIN);
+    MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
+    ContainerResponseContext response = mock(ContainerResponseContext.class);
+    when(response.getHeaders()).thenReturn(headers);
+
+    filter.filter(request, response);
+
+    Assert.assertTrue(headers.isEmpty());
+  }
+
+  @Test
+  public void testResponseWildcardNeverEmitsCredentials() throws IOException {
+    CORSFilter filter = new CORSFilter("*");
+    ContainerRequestContext request = mockRequest("GET", DISALLOWED_ORIGIN);
+    MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
+    ContainerResponseContext response = mock(ContainerResponseContext.class);
+    when(response.getHeaders()).thenReturn(headers);
+
+    filter.filter(request, response);
+
+    Assert.assertEquals(headers.getFirst("Access-Control-Allow-Origin"), "*");
+    Assert.assertNull(headers.getFirst("Access-Control-Allow-Credentials"));
+  }
+
+  @Test
+  public void testUnconfiguredDeniesAllOrigins() throws IOException {
+    CORSFilter filter = new CORSFilter(null);
+    ContainerRequestContext request = mockRequest("GET", ALLOWED_ORIGIN);
+    MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
+    ContainerResponseContext response = mock(ContainerResponseContext.class);
+    when(response.getHeaders()).thenReturn(headers);
+
+    filter.filter(request, response);
+
+    Assert.assertTrue(headers.isEmpty());
+  }
+
+  @Test
+  public void testPreflightAllowedOriginRestrictsMethods() throws IOException {
+    CORSFilter filter = new CORSFilter(ALLOWED_ORIGIN);
+    ContainerRequestContext request = mockRequest("OPTIONS", ALLOWED_ORIGIN);
+    when(request.getHeaderString("Access-Control-Request-Method")).thenReturn("TRACE");
+    when(request.getHeaderString("Access-Control-Request-Headers")).thenReturn("X-Custom");
+
+    Response response = capturePreflight(filter, request);
+
+    Assert.assertEquals(response.getStatus(), 200);
+    Assert.assertEquals(response.getHeaderString("Access-Control-Allow-Origin"), ALLOWED_ORIGIN);
+    Assert.assertEquals(response.getHeaderString("Access-Control-Allow-Credentials"), "true");
+    String allowedMethods = response.getHeaderString("Access-Control-Allow-Methods");
+    Assert.assertNotNull(allowedMethods);
+    Assert.assertFalse(allowedMethods.contains("TRACE"));
+    Assert.assertTrue(allowedMethods.contains("GET"));
+    Assert.assertEquals(response.getHeaderString("Access-Control-Allow-Headers"), "X-Custom");
+  }
+
+  @Test
+  public void testPreflightDisallowedOriginEmitsNoCorsHeaders() throws IOException {
+    CORSFilter filter = new CORSFilter(ALLOWED_ORIGIN);
+    ContainerRequestContext request = mockRequest("OPTIONS", DISALLOWED_ORIGIN);
+    when(request.getHeaderString("Access-Control-Request-Method")).thenReturn("GET");
+
+    Response response = capturePreflight(filter, request);
+
+    Assert.assertNull(response.getHeaderString("Access-Control-Allow-Origin"));
+    Assert.assertNull(response.getHeaderString("Access-Control-Allow-Methods"));
+    Assert.assertNull(response.getHeaderString("Access-Control-Allow-Credentials"));
+  }
+
+  private ContainerRequestContext mockRequest(String method, String origin) {
+    ContainerRequestContext request = mock(ContainerRequestContext.class);
+    when(request.getMethod()).thenReturn(method);
+    when(request.getHeaderString("Origin")).thenReturn(origin);
+    return request;
+  }
+
+  private Response capturePreflight(CORSFilter filter, ContainerRequestContext request)
+      throws IOException {
+    filter.filter(request);
+    ArgumentCaptor<Response> captor = ArgumentCaptor.forClass(Response.class);
+    verify(request).abortWith(captor.capture());
+    return captor.getValue();
+  }
+}