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

thiagohp pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git


The following commit(s) were added to refs/heads/master by this push:
     new 8ce6cb788 TAP5-2720: Tapestry support for CORS
8ce6cb788 is described below

commit 8ce6cb788f1da9a9ff7b36b2861c29330f8aa6c9
Author: Thiago H. de Paula Figueiredo <[email protected]>
AuthorDate: Fri May 27 14:05:08 2022 -0300

    TAP5-2720: Tapestry support for CORS
---
 .../java/org/apache/tapestry5/SymbolConstants.java |  68 +++
 .../integration/app1/services/AppModule.java       |   7 +
 .../apache/tapestry5/http/CorsHandlerResult.java   |  46 ++
 .../http/TapestryHttpSymbolConstants.java          |  62 +++
 .../internal/services/CorsHandlerHelperImpl.java   | 189 ++++++++
 .../http/internal/services/DefaultCorsHandler.java |  77 ++++
 .../tapestry5/http/modules/TapestryHttpModule.java |  27 ++
 .../tapestry5/http/services/CorsHandler.java       |  39 ++
 .../tapestry5/http/services/CorsHandlerHelper.java | 186 ++++++++
 .../services/CorsHttpServletRequestFilter.java     |  71 +++
 .../services/CorsHandlerHelperImplTest.java        | 502 +++++++++++++++++++++
 .../services/CorsHttpServletRequestFilterTest.java |  80 ++++
 .../internal/services/DefaultCorsHandlerTest.java  |  78 ++++
 13 files changed, 1432 insertions(+)

diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java 
b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
index 6d08af38a..e30bfa417 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java
@@ -22,6 +22,9 @@ import org.apache.tapestry5.corelib.mixins.FormGroup;
 import org.apache.tapestry5.http.TapestryHttpConstants;
 import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
 import org.apache.tapestry5.http.services.BaseURLSource;
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
 import org.apache.tapestry5.internal.services.AssetDispatcher;
 import 
org.apache.tapestry5.internal.services.rest.DefaultOpenApiDescriptionGenerator;
 import org.apache.tapestry5.modules.NoBootstrapModule;
@@ -713,4 +716,69 @@ public class SymbolConstants
      */
     public static final String OPENAPI_BASE_PATH = 
"tapestry.openapi-base-path";
 
+    /**
+     * Defines whether the CORS (Cross-Origing Resource Sharing) support 
+     * should be enabled or not. Default value is "false". If you set this to 
"true",
+     * you should also set {@link #CORS_ALLOWED_ORIGINS}.
+     * This is an alias for {@link TapestryHttpSymbolConstants#CORS_ENABLED}.
+     * @see CorsHandler
+     * @see CorsHttpServletRequestFilter
+     * @since 5.8.2
+     */
+    public static final String CORS_ENABLED = 
TapestryHttpSymbolConstants.CORS_ENABLED;
+    
+    /**
+     * Comma-delimited of origins allowed for CORS. The special value "*" 
means allowing all origins.
+     * This is used by {@link 
CorsHandlerHelper#getAllowedOrigin(javax.servlet.http.HttpServletRequest)}.
+     * Default value is the empty string (i.e. no origins allowed and CORS 
actually disabled).
+     * This is an alias for {@link 
TapestryHttpSymbolConstants#CORS_ALLOWED_ORIGINS}.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOWED_ORIGINS = 
TapestryHttpSymbolConstants.CORS_ALLOWED_ORIGINS;
+    
+    /**
+     * Boolean value defining whether the Access-Control-Allow-Credentials 
HTTP header
+     * should be set automatically in the response for CORS requests. Default 
value is
+     * <code>false</code>. This is used in {@link 
CorsHandlerHelper#configureCredentials(javax.servlet.http.HttpServletResponse)}.
+     * This is an alias for {@link 
TapestryHttpSymbolConstants#CORS_ALLOW_CREDENTIALS}.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOW_CREDENTIALS = 
TapestryHttpSymbolConstants.CORS_ALLOW_CREDENTIALS;
+
+    /**
+     * Value to be used in the Access-Control-Allow-Methods in CORS preflight 
request responses.
+     * This is used by {@link 
CorsHandlerHelper#configureMethods(javax.servlet.http.HttpServletResponse)}.
+     * Default value is <code>GET,HEAD,PUT,PATCH,POST,DELETE</code>.
+     * This is an alias for {@link 
TapestryHttpSymbolConstants#CORS_ALLOW_METHODS}.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOW_METHODS = 
TapestryHttpSymbolConstants.CORS_ALLOW_METHODS;
+
+    /**
+     * Value to be used in the Access-Control-Allow-Methods in CORS preflight 
request responses.
+     * This is used by {@link 
CorsHandlerHelper#configureAllowedHeaders(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * This is an alias for {@link 
TapestryHttpSymbolConstants#CORS_ALLOWED_HEADERS}.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOWED_HEADERS = 
TapestryHttpSymbolConstants.CORS_ALLOWED_HEADERS;
+    
+    /**
+     * Value to be used in the Access-Control-Expose-Headers in CORS request 
responses.
+     * This is used by {@link 
CorsHandlerHelper#configureExposeHeaders(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * This is an alias for {@link 
TapestryHttpSymbolConstants#CORS_EXPOSE_HEADERS}.
+     * @since 5.8.2
+     */
+    public static final String CORS_EXPOSE_HEADERS = 
TapestryHttpSymbolConstants.CORS_EXPOSE_HEADERS;
+
+    /**
+     * Value to be used in the Access-Control-Max-Age in responses to 
preflight CORS requests.
+     * This is used by {@link 
CorsHandlerHelper#configureMaxAge(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * This is an alias for {@link TapestryHttpSymbolConstants#CORS_MAX_AGE}.
+     * @since 5.8.2
+     */
+    public static final String CORS_MAX_AGE = 
TapestryHttpSymbolConstants.CORS_MAX_AGE;
+
 }
diff --git 
a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
 
b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
index d41dcf6a4..5f57836bf 100644
--- 
a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
+++ 
b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java
@@ -186,6 +186,13 @@ public class AppModule
         configuration.add(SymbolConstants.OPENAPI_BASE_PATH, "/rest/");
 //        configuration.add(SymbolConstants.ERROR_CSS_CLASS, "yyyy");
 //        configuration.add(SymbolConstants.DEFAULT_STYLESHEET, 
"classpath:/org/apache/tapestry5/integration/app1/app1.css");
+//        configuration.add(SymbolConstants.CORS_ENABLED, true);
+//        configuration.add(SymbolConstants.CORS_ALLOWED_ORIGINS, 
"localhost,localtest.machina.com.br");
+//        configuration.add(SymbolConstants.CORS_ALLOW_METHODS, "GET,PUT");
+//        configuration.add(SymbolConstants.CORS_ALLOWED_HEADERS, "X,Y,Z");
+//        configuration.add(SymbolConstants.CORS_MAX_AGE, "42");
+//        configuration.add(SymbolConstants.CORS_ALLOW_CREDENTIALS, "true");
+//        configuration.add(SymbolConstants.CORS_EXPOSE_HEADERS, "A,B,C");
     }
 
     public static void contributeIgnoredPathsFilter(Configuration<String> 
configuration)
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/CorsHandlerResult.java 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/CorsHandlerResult.java
new file mode 100644
index 000000000..0f7c3f967
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/CorsHandlerResult.java
@@ -0,0 +1,46 @@
+// Licensed 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.
+
+package org.apache.tapestry5.http;
+
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
+import org.apache.tapestry5.http.services.HttpServletRequestFilter;
+
+/**
+ * Enumeration that defines the possible outcomes of 
+ * {@link CorsHandler#handle(javax.servlet.http.HttpServletRequest, 
javax.servlet.http.HttpServletResponse)}
+ * @see CorsHandler
+ * @see CorsHttpServletRequestFilter
+ * @since 5.8.2
+ */
+public enum CorsHandlerResult
+{
+    /**
+     * The next {@linkplain CorsHandler} should be called. 
+     * Usually returned when the CORS handler didn't actually processed the 
request.
+     */
+    CONTINUE_CORS_PROCESSING,
+    
+    /**
+     * No other {@linkplain CorsHandler} should be called and request 
processing
+     * should resume (i.e. {@linkplain CorsHttpServletRequestFilter} calls 
+     * {@link 
HttpServletRequestFilter#service(javax.servlet.http.HttpServletRequest, 
javax.servlet.http.HttpServletResponse, 
org.apache.tapestry5.http.services.HttpServletRequestHandler)}.
+     * Usually returned when the handler actually processed the request.
+     */
+    CONTINUE_REQUEST_PROCESSING,
+    
+    /**
+     * The request processing should stop immediately. Usually used with CORS 
preflight requests.
+     */
+    STOP_REQUEST_PROCESSING
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/TapestryHttpSymbolConstants.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/TapestryHttpSymbolConstants.java
index e570a8359..f7784ac37 100644
--- 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/TapestryHttpSymbolConstants.java
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/TapestryHttpSymbolConstants.java
@@ -11,6 +11,10 @@
 // limitations under the License.
 package org.apache.tapestry5.http;
 
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
+
 /**
  * Class defining constants for Tapestry HTTP symbols.
  */
@@ -132,5 +136,63 @@ final public class TapestryHttpSymbolConstants {
      * @since 5.1.0.0
      */
     public static final String GZIP_COMPRESSION_ENABLED = 
"tapestry.gzip-compression-enabled";
+    /**
+     * Defines whether the CORS (Cross-Origing Resource Sharing) support 
+     * should be enabled or not. Default value is "false". If you set this to 
"true",
+     * you should also set {@link #CORS_ALLOWED_ORIGINS}.
+     * @see CorsHandler
+     * @see CorsHttpServletRequestFilter
+     * @since 5.8.2
+     */
+    public static final String CORS_ENABLED = "tapestry.cors-enabled";
+
+    /**
+     * Comma-delimited of origins allowed for CORS. The special value "*" 
means allowing all origins.
+     * This is used by {@link 
CorsHandlerHelper#getAllowedOrigin(javax.servlet.http.HttpServletRequest)}.
+     * Default value is the empty string (i.e. no origins allowed and CORS 
actually disabled).
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOWED_ORIGINS = 
"tapestry.cors-allowed-origins";
+    
+    /**
+     * Boolean value defining whether the Access-Control-Allow-Credentials 
HTTP header
+     * should be set automatically in the response for CORS requests. Default 
value is
+     * <code>false</code>. This is used in {@link 
CorsHandlerHelper#configureCredentials(javax.servlet.http.HttpServletResponse)}.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOW_CREDENTIALS = 
"tapestry.cors-allow-credentials";
+
+    /**
+     * Value to be used in the Access-Control-Allow-Methods in CORS preflight 
request responses.
+     * This is used by {@link 
CorsHandlerHelper#configureMethods(javax.servlet.http.HttpServletResponse)}.
+     * Default value is <code>GET,HEAD,PUT,PATCH,POST,DELETE</code>.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOW_METHODS = 
"tapestry.cors-allow-methods";
+    
+    /**
+     * Value to be used in the Access-Control-Allow-Methods in CORS preflight 
request responses.
+     * This is used by {@link 
CorsHandlerHelper#configureAllowedHeaders(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * @since 5.8.2
+     */
+    public static final String CORS_ALLOWED_HEADERS = 
"tapestry.cors-allowed-headers";
+    
+    /**
+     * Value to be used in the Access-Control-Expose-Headers in CORS preflight 
request responses.
+     * This is used by {@link 
CorsHandlerHelper#configureExposeHeaders(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * @since 5.8.2
+     */
+    public static final String CORS_EXPOSE_HEADERS = 
"tapestry.cors-expose-headers";
+    
+    /**
+     * Value to be used in the Access-Control-Max-Age in responses to 
preflight CORS requests.
+     * This is used by {@link 
CorsHandlerHelper#configureMaxAge(javax.servlet.http.HttpServletResponse)}.
+     * Default value is the empty string.
+     * This is an alias for {@link TapestryHttpSymbolConstants#CORS_MAX_AGE}.
+     * @since 5.8.2
+     */
+    public static final String CORS_MAX_AGE = "tapestry.cors-max-age";
 
 }
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImpl.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImpl.java
new file mode 100644
index 000000000..c1bab731a
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImpl.java
@@ -0,0 +1,189 @@
+// Licensed 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.
+package org.apache.tapestry5.http.internal.services;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.apache.tapestry5.ioc.annotations.Symbol;
+
+/**
+ * Default {@link CorsHandlerHelper} implementation.
+ * 
+ * @see CorsHandler
+ * @see TapestryHttpSymbolConstants#CORS_ALLOWED_ORIGINS.
+ * @since 5.8.2
+ */
+public class CorsHandlerHelperImpl implements CorsHandlerHelper 
+{
+    
+    private final List<String> allowedOrigins;
+    
+    private final boolean allowAllOrigins;
+    private final boolean allowCredentials;
+    private final String allowMethods;
+    private final String allowedHeaders;
+    private final String exposeHeaders;
+    private final String maxAge;
+    
+    public CorsHandlerHelperImpl(
+            @Symbol(TapestryHttpSymbolConstants.CORS_ALLOWED_ORIGINS) String 
allowedOrigins,
+            @Symbol(TapestryHttpSymbolConstants.CORS_ALLOW_CREDENTIALS) 
boolean allowCredentials,
+            @Symbol(TapestryHttpSymbolConstants.CORS_ALLOW_METHODS) String 
allowMethods,
+            @Symbol(TapestryHttpSymbolConstants.CORS_ALLOWED_HEADERS) String 
allowedHeaders,
+            @Symbol(TapestryHttpSymbolConstants.CORS_EXPOSE_HEADERS) String 
exposeHeaders,
+            @Symbol(TapestryHttpSymbolConstants.CORS_MAX_AGE) String maxAge)
+    {
+        allowAllOrigins = ORIGIN_WILDCARD.equals(allowedOrigins);
+        this.allowedOrigins = !allowAllOrigins ? 
Arrays.asList(trim(allowedOrigins.split(","))) : null;
+        this.allowCredentials = allowCredentials;
+        this.allowMethods = allowMethods.trim();
+        this.allowedHeaders = allowedHeaders.trim();
+        this.exposeHeaders = exposeHeaders.trim();
+        this.maxAge = maxAge.trim();
+    }
+
+    @Override
+    public String getPath(HttpServletRequest request) 
+    {
+        
+        // Copied from RequestImpl.getRequest()
+        String pathInfo = request.getPathInfo();
+
+        if (pathInfo == null)
+        {
+            return request.getServletPath();
+        }
+
+        // Websphere 6.1 is a bit wonky (see TAPESTRY-1713), and tends to 
return the empty string
+        // for the servlet path, and return the true path in pathInfo.
+
+        return pathInfo.length() == 0 ? "/" : pathInfo;
+    }
+
+    @Override
+    public Optional<String> getAllowedOrigin(HttpServletRequest request) 
+    {
+        final Optional<String> allowedOrigin;
+        final Optional<String> origin = getOrigin(request);
+    
+        if (allowAllOrigins)
+        {
+            allowedOrigin = Optional.of(ORIGIN_WILDCARD);
+        }
+        else if (origin.isPresent() && allowedOrigins.contains(origin.get()))
+        {
+            allowedOrigin = origin;
+        }
+        else
+        {
+            allowedOrigin = Optional.empty();
+        }
+        
+        return allowedOrigin;
+    }
+
+    @Override
+    public Optional<String> getOrigin(HttpServletRequest request) 
+    {
+        return Optional.ofNullable(request.getHeader(ORIGIN_HEADER));
+    }
+
+    @Override
+    public boolean isPreflight(HttpServletRequest request) 
+    {
+        boolean preflight = false;
+        if (OPTIONS_METHOD.equals(request.getMethod()))
+        {
+            final Optional<String> origin = getAllowedOrigin(request);
+            preflight = origin.isPresent() && !origin.get().trim().isEmpty();
+        }
+        return preflight;
+    }
+    
+    @Override
+    public void configureOrigin(HttpServletResponse response, String value) 
+    {
+        response.setHeader(ALLOW_ORIGIN_HEADER, value);
+        addValueToVaryHeader(response, ORIGIN_HEADER);
+    }
+
+    @Override
+    public void configureCredentials(HttpServletResponse response) 
+    {
+        if (allowCredentials)
+        {
+            response.setHeader(ALLOW_CREDENTIALS_HEADER, "true");
+        }
+    }
+
+    @Override
+    public void configureMethods(HttpServletResponse response) 
+    {
+        response.setHeader(ALLOW_METHODS_HEADER, allowMethods);
+    }
+
+    @Override
+    public void configureAllowedHeaders(HttpServletResponse response, 
HttpServletRequest request) 
+    {
+        String value;
+        if (allowedHeaders.isEmpty())
+        {
+            value = request.getHeader(REQUEST_HEADERS_HEADER);
+            addValueToVaryHeader(response, REQUEST_HEADERS_HEADER);
+        }
+        else 
+        {
+            value = allowedHeaders;
+        }
+        if (value != null && !value.isEmpty())
+        {
+            response.setHeader(ALLOW_HEADERS_HEADER, value);
+        }
+    }
+    
+
+    @Override
+    public void configureExposeHeaders(HttpServletResponse response) 
+    {
+        if (!exposeHeaders.isEmpty())
+        {
+            response.setHeader(EXPOSE_HEADERS_HEADER, exposeHeaders);
+        }
+    }
+    
+    @Override
+    public void configureMaxAge(HttpServletResponse response) 
+    {
+        if (!maxAge.isEmpty())
+        {
+            response.setHeader(MAX_AGE_HEADER, maxAge);
+        }
+    }
+    
+
+    private static String[] trim(String[] strings) {
+        for (int i = 0; i < strings.length; i++)
+        {
+            strings[i] = strings[i].trim();
+        }
+        return strings;
+    }
+
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandler.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandler.java
new file mode 100644
index 000000000..b14f9a284
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandler.java
@@ -0,0 +1,77 @@
+// Licensed 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.
+package org.apache.tapestry5.http.internal.services;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.CorsHandlerResult;
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+
+/**
+ * <p>
+ * Default {@link CorsHandler} implementation. It will process all requests 
with an Origin HTTP header,
+ * regardless of path. It will also perform preflight requests if 
+ * {@link CorsHandlerHelper#isPreflight(HttpServletRequest)}
+ * returns <code>true</code>. Most logic is delegated is {@link 
CorsHandlerHelper}.
+ * <p>
+ * <p>
+ * This implementation is inspired by the cors NPM module.
+ * </p>
+ * @see CorsHandlerHelper
+ * @since 5.8.2
+ */
+public class DefaultCorsHandler implements CorsHandler 
+{
+    
+    private final CorsHandlerHelper helper;
+
+    public DefaultCorsHandler(CorsHandlerHelper helper) 
+    {
+        this.helper = helper;
+    }
+
+    @Override
+    public CorsHandlerResult handle(HttpServletRequest request, 
HttpServletResponse response) throws IOException 
+    {
+        
+        CorsHandlerResult result = CorsHandlerResult.CONTINUE_CORS_PROCESSING;
+        final Optional<String> allowedOrigin = 
helper.getAllowedOrigin(request);
+        if (helper.isPreflight(request))
+        {
+            if (allowedOrigin.isPresent())
+            {
+                helper.configureOrigin(response, allowedOrigin.get());
+                helper.configureCredentials(response);
+                helper.configureExposeHeaders(response);
+                helper.configureMethods(response);
+                helper.configureAllowedHeaders(response, request);
+                helper.configureMaxAge(response);
+                response.setContentLength(0);
+                response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+                result = CorsHandlerResult.STOP_REQUEST_PROCESSING;
+            }
+        }
+        else if (allowedOrigin.isPresent())
+        {
+            helper.configureOrigin(response, allowedOrigin.get());
+            helper.configureCredentials(response);
+            helper.configureExposeHeaders(response);
+        }
+        return result;
+    }
+
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
index c01ae6366..8b4d5a83e 100644
--- 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/modules/TapestryHttpModule.java
@@ -39,6 +39,8 @@ import 
org.apache.tapestry5.http.internal.services.ApplicationGlobalsImpl;
 import org.apache.tapestry5.http.internal.services.AsyncRequestServiceImpl;
 import org.apache.tapestry5.http.internal.services.BaseURLSourceImpl;
 import org.apache.tapestry5.http.internal.services.ContextImpl;
+import org.apache.tapestry5.http.internal.services.CorsHandlerHelperImpl;
+import org.apache.tapestry5.http.internal.services.DefaultCorsHandler;
 import 
org.apache.tapestry5.http.internal.services.DefaultSessionPersistedObjectAnalyzer;
 import 
org.apache.tapestry5.http.internal.services.OptimizedSessionPersistedObjectAnalyzer;
 import org.apache.tapestry5.http.internal.services.RequestGlobalsImpl;
@@ -53,6 +55,9 @@ import 
org.apache.tapestry5.http.services.ApplicationInitializer;
 import org.apache.tapestry5.http.services.ApplicationInitializerFilter;
 import org.apache.tapestry5.http.services.BaseURLSource;
 import org.apache.tapestry5.http.services.Context;
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
 import org.apache.tapestry5.http.services.Dispatcher;
 import org.apache.tapestry5.http.services.HttpRequestBodyConverter;
 import org.apache.tapestry5.http.services.HttpServletRequestFilter;
@@ -107,6 +112,8 @@ public final class TapestryHttpModule {
         binder.bind(ResponseCompressionAnalyzer.class, 
ResponseCompressionAnalyzerImpl.class);
         binder.bind(RestSupport.class, RestSupportImpl.class);
         binder.bind(AsyncRequestService.class, AsyncRequestServiceImpl.class);
+        binder.bind(CorsHandlerHelper.class, CorsHandlerHelperImpl.class);
+        binder.bind(CorsHttpServletRequestFilter.class);
     }
     
     /**
@@ -123,6 +130,13 @@ public final class TapestryHttpModule {
         configuration.add(TapestryHttpConstants.TAPESTRY_VERSION,
                 
VersionUtils.readVersionNumber("META-INF/gradle/org.apache.tapestry/tapestry-http/project.properties"));
         
+        configuration.add(TapestryHttpSymbolConstants.CORS_ENABLED, "false");
+        configuration.add(TapestryHttpSymbolConstants.CORS_ALLOWED_ORIGINS, 
"");
+        configuration.add(TapestryHttpSymbolConstants.CORS_ALLOW_CREDENTIALS, 
"false");
+        configuration.add(TapestryHttpSymbolConstants.CORS_ALLOW_METHODS, 
"GET,HEAD,PUT,PATCH,POST,DELETE");
+        configuration.add(TapestryHttpSymbolConstants.CORS_ALLOWED_HEADERS, 
"");
+        configuration.add(TapestryHttpSymbolConstants.CORS_EXPOSE_HEADERS, "");
+        
         // The default values denote "use values from request"
         configuration.add(TapestryHttpSymbolConstants.HOSTNAME, "");
         configuration.add(TapestryHttpSymbolConstants.HOSTPORT, 0);
@@ -290,6 +304,8 @@ public final class TapestryHttpModule {
      */
     public void 
contributeHttpServletRequestHandler(OrderedConfiguration<HttpServletRequestFilter>
 configuration,                         
             @Symbol(TapestryHttpSymbolConstants.GZIP_COMPRESSION_ENABLED) 
boolean gzipCompressionEnabled, 
+            @Symbol(TapestryHttpSymbolConstants.CORS_ENABLED) boolean 
corsEnabled, 
+            CorsHttpServletRequestFilter corsHttpServletRequestFilter,
             @Autobuild GZipFilter gzipFilter)
     {
         
@@ -303,6 +319,12 @@ public final class TapestryHttpModule {
                 return handler.service(request, response);
             }
         };
+        
+        if (corsEnabled)
+        {
+            configuration.add("CORS", corsHttpServletRequestFilter, 
+                    "after:StoreIntoGlobals", "before:GZIP");
+        }
 
         configuration.add("StoreIntoGlobals", storeIntoGlobals, "before:*");
         
@@ -333,6 +355,11 @@ public final class TapestryHttpModule {
         CoercionTuple.add(configuration, HttpServletRequest.class, 
BufferedReader.class, TapestryHttpModule::toBufferedReader);
     }
     
+    public static void 
contributeCorsHttpServletRequestFilter(OrderedConfiguration<CorsHandler> 
configuration)
+    {
+        configuration.addInstance("Default", DefaultCorsHandler.class, 
"after:*");
+    }
+    
     private final static InputStream toInputStream(HttpServletRequest request)
     {
         try 
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandler.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandler.java
new file mode 100644
index 000000000..6d8b790fa
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandler.java
@@ -0,0 +1,39 @@
+// Licensed 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.
+
+package org.apache.tapestry5.http.services;
+
+import java.io.IOException;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.CorsHandlerResult;
+
+/**
+ * Interface implemented by classes handling CORS (Cross-Origin Resource 
Sharing) in requests.
+ * Implementations should be contributed to {@link 
CorsHttpServletRequestFilter} and 
+ * can use the useful methods from {@link CorsHandlerHelper}.
+ * @see CorsHandlerHelper
+ * @see CorsHandlerResult
+ * @see CorsHttpServletRequestFilter
+ * @since 5.8.2
+ */
+public interface CorsHandler
+{
+    /**
+     * Handles the CORS processing of a request, possibly doing nothing. This 
method
+     * cannot return <code>null</code>.
+     * @return a {@linkplain CorsHandlerResult} object. 
+     */
+    CorsHandlerResult handle(HttpServletRequest request, HttpServletResponse 
response) throws IOException;
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandlerHelper.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandlerHelper.java
new file mode 100644
index 000000000..a0af7f18e
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHandlerHelper.java
@@ -0,0 +1,186 @@
+// Licensed 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.
+package org.apache.tapestry5.http.services;
+
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
+
+/**
+ * Service that provides useful methods for {@link CorsHandler} 
implementations.
+ * 
+ * @see CorsHandler
+ * @since 5.8.2
+ */
+public interface CorsHandlerHelper {
+    
+    /**
+     * Returns the requested URL path, using the same logic as {@link 
Request#getPath()}.
+     * @param request an {@link HttpServletRequest}.
+     * @return a {@link String} with the path.
+     */
+    String getPath(HttpServletRequest request);
+    
+    /**
+     * Returns the value of the Origin HTTP header.
+     * @param request an {@link HttpServletRequest}.
+     * @return an {@link Optional} wrapping the Origin HTTP header value.
+     */
+    Optional<String> getOrigin(HttpServletRequest request);
+
+    /**
+     * Tells whether this request is a CORS preflight one (i.e. HTTP method 
OPTION and 
+     * non-empty Origin HTTP header).
+     * 
+     * @param request an {@link HttpServletRequest}.
+     * @return <code>true</code> if it's a preflight request, 
<code>false</code> otherwise.
+     */
+    boolean isPreflight(HttpServletRequest request);
+    
+    /**
+     * Returns the origin of this requests, if it's allowed. 
+     * @param request an {@link HttpServletRequest}.
+     * @return an {@link Optional} wrapping the allowed origin.
+     * @see TapestryHttpSymbolConstants#CORS_ALLOWED_ORIGINS
+     */
+    Optional<String> getAllowedOrigin(HttpServletRequest request);
+    
+    /**
+     * Sets the Access-Control-Allow-Origin HTTP header with a given value.
+     * @param value a {@linkplain String}.
+     * @param response an {@link HttpServletResponse} instance.
+     * @see #ALLOW_ORIGIN_HEADER
+     */
+    void configureOrigin(HttpServletResponse response, String value);
+
+    /**
+     * Conditionally sets the Access-Control-Allow-Credentials HTTP header.
+     * Out-of-the-box, this is done based on the 
+     * {@link TapestryHttpSymbolConstants#CORS_ALLOW_CREDENTIALS} symbol.
+     * @param response an {@link HttpServletResponse}.
+     * @see TapestryHttpSymbolConstants#CORS_ALLOW_CREDENTIALS
+     */
+    void configureCredentials(HttpServletResponse response);
+
+    /**
+     * Conditionally sets the Access-Control-Allow-Methods HTTP header in 
responses
+     * to preflight CORS requests.
+     * Out-of-the-box, the value comes from 
+     * {@link TapestryHttpSymbolConstants#CORS_ALLOW_CREDENTIALS} symbol
+     * and the header is only set if the value isn't empty.
+     * @param response an {@link HttpServletResponse}.
+     * @see TapestryHttpSymbolConstants#CORS_ALLOW_CREDENTIALS
+     */
+    void configureMethods(HttpServletResponse response);
+    
+    /**
+     * Conditionally the Access-Control-Request-Headers HTTP header.
+     * Out-of-the-box, the value comes from 
+     * {@link TapestryHttpSymbolConstants#CORS_ALLOWED_HEADERS} symbol if not 
empty.
+     * Otherwise, it comes from the value of the same HTTP header from the 
request, 
+     * also if not empty. Otherwise, the header isn't set.
+     * @param response an {@link HttpServletResponse}.
+     * @param request an {@link HttpServletRequest}.
+     * @see TapestryHttpSymbolConstants#CORS_ALLOWED_HEADERS
+     */
+    void configureAllowedHeaders(HttpServletResponse response, 
HttpServletRequest request);
+    
+    /**
+     * Conditionally sets the Access-Control-Expose-Headers HTTP header.
+     * Out-of-the-box, the value comes from 
+     * {@link TapestryHttpSymbolConstants#CORS_EXPOSED_HEADERS} symbol, if not 
empty.
+     * Otherwise, the header isn't set.
+     * @param response an {@link HttpServletResponse}.
+     * @see TapestryHttpSymbolConstants#CORS_EXPOSED_HEADERS
+     */
+    void configureExposeHeaders(HttpServletResponse response);
+
+    /**
+     * Conditionally sets the Access-Control-Max-Age HTTP header.
+     * Out-of-the-box, the value comes from 
+     * {@link TapestryHttpSymbolConstants#CORS_MAX_AGE} symbol, if not empty.
+     * Otherwise, the header isn't set.
+     * @param response an {@link HttpServletResponse}.
+     * @see TapestryHttpSymbolConstants#CORS_MAX_AGE
+     */
+    void configureMaxAge(HttpServletResponse response);
+    
+    /**
+     * Adds a value to the Vary HTTP header.
+     * @param response an {@link HttpServletResponse} instance.
+     * @param value the value to be added. 
+     */
+    default void addValueToVaryHeader(HttpServletResponse response, String 
value)
+    {
+        final String vary = response.getHeader(VARY_HEADER);
+        response.setHeader(VARY_HEADER, vary == null ? value : vary + ", " + 
value);
+    }
+    
+    /**
+     * Name of the Origin HTTP header.
+     */
+    String ORIGIN_HEADER = "Origin";
+
+    /**
+     * Name of the Access-Control-Allow-Origin HTTP header.
+     */
+    String ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
+
+    /**
+     * Name of the Access-Control-Allow-Credentials HTTP header.
+     */
+    String ALLOW_CREDENTIALS_HEADER = "Access-Control-Allow-Credentials";
+    
+    /**
+     * Name of the Access-Control-Allow-Methods HTTP header.
+     */
+    String ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods";
+
+    /**
+     * Name of the Access-Control-Request-Headers HTTP header.
+     */
+    String REQUEST_HEADERS_HEADER = "Access-Control-Request-Headers";
+
+    /**
+     * Name of the Access-Control-Allow-Headers HTTP header.
+     */
+    String ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers";
+    
+    /**
+     * Name of the Access-Control-Expose-Headers HTTP header.
+     */
+    String EXPOSE_HEADERS_HEADER = "Access-Control-Expose-Headers";
+
+    /**
+     * Name of the Access-Control-Max-Age HTTP header.
+     */
+    String MAX_AGE_HEADER = "Access-Control-Max-Age";
+
+    /**
+     * Name of the Vary HTTP header.
+     */
+    String VARY_HEADER = "Vary";
+
+    /**
+     * OPTIONS HTTP method name.
+     */
+    String OPTIONS_METHOD = "OPTIONS";
+
+    /**
+     * The CORS Origin wildcard.
+     */
+    String ORIGIN_WILDCARD = "*";
+
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHttpServletRequestFilter.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHttpServletRequestFilter.java
new file mode 100644
index 000000000..07d3deb26
--- /dev/null
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/CorsHttpServletRequestFilter.java
@@ -0,0 +1,71 @@
+// Licensed 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.
+
+package org.apache.tapestry5.http.services;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.CorsHandlerResult;
+import org.apache.tapestry5.ioc.annotations.UsesOrderedConfiguration;
+
+/**
+ * {@linkplain HttpServletRequestFilter} that handles CORS. Uses an ordered 
configuration of
+ * {@linkplain CorsHandler} instances.
+ * @see CorsHandler
+ * @since 5.8.2
+ */
+@UsesOrderedConfiguration(CorsHandler.class)
+public class CorsHttpServletRequestFilter implements HttpServletRequestFilter
+{
+
+    final List<CorsHandler> handlers;
+    
+    public CorsHttpServletRequestFilter(List<CorsHandler> handlers) 
+    {
+        this.handlers = new ArrayList<>(handlers);
+    }
+
+    @Override
+    public boolean service(HttpServletRequest request, HttpServletResponse 
response, HttpServletRequestHandler handler) throws IOException 
+    {
+        
+        boolean serviced = false;
+        for (CorsHandler corsHandler : handlers)
+        {
+            
+            final CorsHandlerResult result = corsHandler.handle(request, 
response);
+            if (result.equals(CorsHandlerResult.CONTINUE_REQUEST_PROCESSING)) 
+            {
+                break;
+            }
+            else if (result.equals(CorsHandlerResult.STOP_REQUEST_PROCESSING)) 
+            {
+                serviced = true;
+                break;
+            }
+            
+        }
+        
+        if (!serviced) 
+        {
+            serviced = handler.service(request, response);
+        }
+        
+        return serviced;
+    }
+
+}
diff --git 
a/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImplTest.java
 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImplTest.java
new file mode 100644
index 000000000..10632f99b
--- /dev/null
+++ 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHandlerHelperImplTest.java
@@ -0,0 +1,502 @@
+package org.apache.tapestry5.http.internal.services;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Test suite for {@link CorsHandlerHelperImpl.
+ */
+public class CorsHandlerHelperImplTest {
+
+    
+    @Test
+    public void get_origin()
+    {
+        
+        final String[] allowedOrigins = new String[] 
{"http://tapestry.apache.org";, "https://apache.org"};
+        
+        // Scenario 1: explicit origins
+        
+        CorsHandlerHelper helper = create(String.join("  ,  ", 
allowedOrigins));
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+        
+        for (String origin : allowedOrigins)
+        {
+
+            // Allowed origins
+            EasyMock.reset(request);
+            
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(origin);
+            EasyMock.replay(request);
+            Assert.assertEquals("Allowed origin", origin, 
helper.getAllowedOrigin(request).get());
+            EasyMock.verify(request);
+            
+            // Non-allowed origins
+            EasyMock.reset(request);
+            
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(origin
 + "baaaa");
+            EasyMock.replay(request);
+            Assert.assertFalse("Non-allowed origins", 
helper.getAllowedOrigin(request).isPresent());
+            EasyMock.verify(request);
+            
+        }
+        
+        // Scenario 2: allow all origins
+        
+        helper = create(CorsHandlerHelper.ORIGIN_WILDCARD);
+        for (int i = 0; i < 10; i++)
+        {
+            final String origin = "https://web"; + i + ".tapestry.apache.org";
+            EasyMock.reset(request);
+            
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(origin);
+            EasyMock.replay(request);
+            Assert.assertEquals("All origins should be accepted", 
CorsHandlerHelper.ORIGIN_WILDCARD, helper.getAllowedOrigin(request).get());
+            EasyMock.verify(request);
+        }
+        
+    }
+
+    @Test
+    public void is_preflight()
+    {
+        
+        // Scenario 1: not OPTIONS
+        final String origin = "https://tapestry.apache.org";;
+        CorsHandlerHelper helper = create(origin);
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+        EasyMock.expect(request.getMethod()).andReturn("PUT");
+        EasyMock.replay(request);
+        Assert.assertFalse("Not OPTIONS", helper.isPreflight(request));
+        EasyMock.verify(request);
+
+        // Scenario 2: no Origin
+        EasyMock.reset(request);
+        
EasyMock.expect(request.getMethod()).andReturn(CorsHandlerHelper.OPTIONS_METHOD);
+        
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(null);
+        EasyMock.replay(request);
+        Assert.assertFalse("No Origin", helper.isPreflight(request));
+        EasyMock.verify(request);
+
+        // Scenario 3: accepted origin
+        EasyMock.reset(request);
+        
EasyMock.expect(request.getMethod()).andReturn(CorsHandlerHelper.OPTIONS_METHOD);
+        
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(origin);
+        EasyMock.replay(request);
+        Assert.assertTrue("Preflight indeed", helper.isPreflight(request));
+        EasyMock.verify(request);
+
+        // Scenario 4: non-accepted origin
+        EasyMock.reset(request);
+        
EasyMock.expect(request.getMethod()).andReturn(CorsHandlerHelper.OPTIONS_METHOD);
+        
EasyMock.expect(request.getHeader(CorsHandlerHelper.ORIGIN_HEADER)).andReturn(origin
 + "baaah");
+        EasyMock.replay(request);
+        Assert.assertFalse("Non-accepted origin", helper.isPreflight(request));
+        EasyMock.verify(request);
+
+    }
+    
+    @Test
+    public void get_path()
+    {
+
+        CorsHandlerHelper helper = create("*");
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+        
+        final String pathInfo = "/blah/foo/";
+        EasyMock.expect(request.getPathInfo()).andReturn(pathInfo);
+        EasyMock.replay(request);
+        Assert.assertEquals(pathInfo, helper.getPath(request));
+        EasyMock.verify(request);
+        
+        EasyMock.reset(request);
+        EasyMock.expect(request.getPathInfo()).andReturn("");
+        EasyMock.replay(request);
+        Assert.assertEquals("/", helper.getPath(request));
+        EasyMock.verify(request);
+
+        final String servletPath = "/servlet";
+        EasyMock.reset(request);
+        EasyMock.expect(request.getPathInfo()).andReturn(null);
+        EasyMock.expect(request.getServletPath()).andReturn(servletPath);
+        EasyMock.replay(request);
+        Assert.assertEquals(servletPath, helper.getPath(request));
+        EasyMock.verify(request);
+
+    }
+    
+    @Test
+    public void configure_origin()
+    {
+        CorsHandlerHelper helper = create("*");
+        HttpServletResponse response = new TestableHttpServletResponse();;
+        
+        final String origin = "https://apache.org";;
+        helper.configureOrigin(response, origin);
+        Assert.assertEquals(origin, 
response.getHeader(CorsHandlerHelper.ALLOW_ORIGIN_HEADER));
+        Assert.assertEquals(CorsHandlerHelper.ORIGIN_HEADER, 
response.getHeader(CorsHandlerHelper.VARY_HEADER));
+    }
+    
+    @Test
+    public void add_value_to_vary_header()
+    {
+        String[] values = new String[] {"Something", "else", "entirely"};
+        HttpServletResponse response = new TestableHttpServletResponse();
+        CorsHandlerHelper helper = create(CorsHandlerHelper.ORIGIN_WILDCARD);
+        for (String value : values) {
+            helper.addValueToVaryHeader(response, value);
+        }
+        Assert.assertEquals(String.join(", ", values), 
response.getHeader(CorsHandlerHelper.VARY_HEADER));
+    }
+    
+    @Test
+    public void configure_credentials()
+    {
+        HttpServletResponse response = new TestableHttpServletResponse();
+        
+        CorsHandlerHelper helper = create(CorsHandlerHelper.ORIGIN_WILDCARD, 
true);
+        helper.configureCredentials(response);
+        Assert.assertEquals("true", 
response.getHeader(CorsHandlerHelper.ALLOW_CREDENTIALS_HEADER));
+        
+        response = new TestableHttpServletResponse();
+
+        helper = create(CorsHandlerHelper.ORIGIN_WILDCARD, false);
+        helper.configureCredentials(response);
+        Assert.assertNull("Header shouldn't be set", 
+                
response.getHeader(CorsHandlerHelper.ALLOW_CREDENTIALS_HEADER));
+
+    }
+    
+    @Test
+    public void configure_methods()
+    {
+        HttpServletResponse response = new TestableHttpServletResponse();
+        
+        final String allowedMethods = "GET,PUT";
+        CorsHandlerHelper helper = new CorsHandlerHelperImpl("", false, 
allowedMethods, "", "", "");
+        response = new TestableHttpServletResponse();
+        helper.configureMethods(response);
+        Assert.assertEquals(allowedMethods, 
response.getHeader(CorsHandlerHelper.ALLOW_METHODS_HEADER));
+    }
+
+    @Test
+    public void configure_allowed_headers()
+    {
+        HttpServletResponse response = new TestableHttpServletResponse();
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+     
+        // Scenario 1: configuration isn't empty
+        final String configuredHeaders = "Blah,Foo";
+        CorsHandlerHelper helper = new CorsHandlerHelperImpl("", false, "", 
configuredHeaders, "", "");
+        EasyMock.replay(request);
+        helper.configureAllowedHeaders(response, request);
+        EasyMock.verify(request);
+        Assert.assertEquals(configuredHeaders, 
response.getHeader(CorsHandlerHelper.ALLOW_HEADERS_HEADER));
+        Assert.assertNull(response.getHeader(CorsHandlerHelper.VARY_HEADER));
+
+        // Scenario 2: configuration empty, request's 
Access-Control-Request-Headers not empty
+        response = new TestableHttpServletResponse();
+        final String requestHeaders = "Foo,Blah";
+        helper = new CorsHandlerHelperImpl("", false, "", "", "", "");
+        EasyMock.reset(request);
+        
EasyMock.expect(request.getHeader(CorsHandlerHelper.REQUEST_HEADERS_HEADER)).andReturn(requestHeaders);
+        EasyMock.replay(request);
+        helper.configureAllowedHeaders(response, request);
+        EasyMock.verify(request);
+        Assert.assertEquals(requestHeaders, 
response.getHeader(CorsHandlerHelper.ALLOW_HEADERS_HEADER));
+        Assert.assertEquals(CorsHandlerHelper.REQUEST_HEADERS_HEADER, 
response.getHeader(CorsHandlerHelper.VARY_HEADER));
+
+        // Scenario 3: configuration empty, request's 
Access-Control-Request-Headers also empty
+        response = new TestableHttpServletResponse();
+        EasyMock.reset(request);
+        
EasyMock.expect(request.getHeader(CorsHandlerHelper.REQUEST_HEADERS_HEADER)).andReturn(null);
+        EasyMock.replay(request);
+        helper.configureAllowedHeaders(response, request);
+        EasyMock.verify(request);
+        
Assert.assertNull(response.getHeader(CorsHandlerHelper.ALLOW_HEADERS_HEADER));
+        Assert.assertEquals(CorsHandlerHelper.REQUEST_HEADERS_HEADER, 
response.getHeader(CorsHandlerHelper.VARY_HEADER));
+
+    }
+
+    @Test
+    public void configure_expose_headers()
+    {
+        HttpServletResponse response = new TestableHttpServletResponse();
+        
+        final String exposeHeaders = "Blah,Foo,Bar";
+        CorsHandlerHelper helper = new CorsHandlerHelperImpl("", false, "", 
"", exposeHeaders, "");
+        helper.configureExposeHeaders(response);
+        Assert.assertEquals(exposeHeaders, 
response.getHeader(CorsHandlerHelper.EXPOSE_HEADERS_HEADER));
+        
+        response = new TestableHttpServletResponse();
+
+        helper = new CorsHandlerHelperImpl("", false, "", "", "", "");
+        helper.configureExposeHeaders(response);
+        Assert.assertNull("Header shouldn't be set", 
+                response.getHeader(CorsHandlerHelper.EXPOSE_HEADERS_HEADER));
+
+    }
+
+    @Test
+    public void configure_max_age()
+    {
+        HttpServletResponse response = new TestableHttpServletResponse();
+        
+        final String maxAge = "1234567";
+        CorsHandlerHelper helper = new CorsHandlerHelperImpl("", false, "", 
"", "", maxAge);
+        helper.configureMaxAge(response);
+        Assert.assertEquals(maxAge, 
response.getHeader(CorsHandlerHelper.MAX_AGE_HEADER));
+        
+        response = new TestableHttpServletResponse();
+
+        helper = new CorsHandlerHelperImpl("", false, "", "", "", "");
+        helper.configureExposeHeaders(response);
+        Assert.assertNull("Header shouldn't be set", 
+                response.getHeader(CorsHandlerHelper.MAX_AGE_HEADER));
+
+    }
+
+    private CorsHandlerHelper create(String allowedOrigins)
+    {
+        return create(allowedOrigins, false);
+    }
+    
+    private CorsHandlerHelper create(String allowedOrigins, boolean 
allowCredentials)
+    {
+        return new CorsHandlerHelperImpl(allowedOrigins, allowCredentials, "", 
"", "", "");
+    }
+    
+    private final class TestableHttpServletResponse implements 
HttpServletResponse
+    {
+        
+        private final Map<String, String> headers = new HashMap<>();
+
+        @Override
+        public String getCharacterEncoding() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getContentType() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public ServletOutputStream getOutputStream() throws IOException 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public PrintWriter getWriter() throws IOException 
+        {
+            return null;
+        }
+
+        @Override
+        public void setCharacterEncoding(String charset) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setContentLength(int len) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setContentType(String type) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setBufferSize(int size) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getBufferSize() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void flushBuffer() throws IOException 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void resetBuffer() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isCommitted() 
+        {
+            return false;
+        }
+
+        @Override
+        public void reset() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setLocale(Locale loc) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Locale getLocale() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void addCookie(Cookie cookie) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean containsHeader(String name) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String encodeURL(String url) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String encodeRedirectURL(String url) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String encodeUrl(String url) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String encodeRedirectUrl(String url) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void sendError(int sc, String msg) throws IOException 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void sendError(int sc) throws IOException 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void sendRedirect(String location) throws IOException 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setDateHeader(String name, long date) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void addDateHeader(String name, long date) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setHeader(String name, String value) 
+        {
+            headers.put(name, value);
+        }
+
+        @Override
+        public void addHeader(String name, String value) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setIntHeader(String name, int value) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void addIntHeader(String name, int value) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setStatus(int sc) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setStatus(int sc, String sm) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getStatus() 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getHeader(String name) {
+            return headers.get(name);
+        }
+
+        @Override
+        public Collection<String> getHeaders(String name) 
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Collection<String> getHeaderNames() 
+        {
+            throw new UnsupportedOperationException();
+        }
+        
+    }
+    
+}
diff --git 
a/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHttpServletRequestFilterTest.java
 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHttpServletRequestFilterTest.java
new file mode 100644
index 000000000..c39334425
--- /dev/null
+++ 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/CorsHttpServletRequestFilterTest.java
@@ -0,0 +1,80 @@
+package org.apache.tapestry5.http.internal.services;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.CorsHandlerResult;
+import org.apache.tapestry5.http.services.CorsHandler;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
+import org.apache.tapestry5.http.services.HttpServletRequestHandler;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Test suite for {@link CorsHttpServletRequestFilter}.
+ */
+public class CorsHttpServletRequestFilterTest {
+    
+    @Test
+    public void service() throws IOException
+    {
+        
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+        HttpServletResponse response = 
EasyMock.createMock(HttpServletResponse.class);
+        HttpServletRequestHandler httpServletRequestHandler = 
EasyMock.createMock(HttpServletRequestHandler.class);
+        
+        CorsHandler stopRequestProcessing = (req, res) -> 
CorsHandlerResult.STOP_REQUEST_PROCESSING;
+        CorsHandler continueRequestProcessing = (req, res) -> 
CorsHandlerResult.CONTINUE_REQUEST_PROCESSING;
+        CorsHandler throwException = (req, res) -> { throw new 
RuntimeException("Shouldn't have been called"); };
+        TestCorsHandler testCorsHandler = new TestCorsHandler();
+        TestCorsHandler testCorsHandler2 = new TestCorsHandler();
+        
+        // Scenario 1: all handlers return continue CORS processing.
+        CorsHttpServletRequestFilter filter = 
+                new 
CorsHttpServletRequestFilter(Arrays.asList(testCorsHandler, testCorsHandler2));
+        EasyMock.expect(httpServletRequestHandler.service(request, 
response)).andReturn(true);
+        EasyMock.replay(httpServletRequestHandler);
+        filter.service(request, response, httpServletRequestHandler);
+        EasyMock.verify(httpServletRequestHandler);
+        Assert.assertTrue(testCorsHandler.isCalled());
+        Assert.assertTrue(testCorsHandler2.isCalled());
+
+        // Scenario 2: first handler stops CORS processing
+        EasyMock.reset(httpServletRequestHandler);
+        filter = new 
CorsHttpServletRequestFilter(Arrays.asList(continueRequestProcessing, 
throwException));
+        EasyMock.expect(httpServletRequestHandler.service(request, 
response)).andReturn(true);
+        EasyMock.replay(httpServletRequestHandler);
+        filter.service(request, response, httpServletRequestHandler);
+        EasyMock.verify(httpServletRequestHandler);
+
+        // Scenario 3: first handler stops request processing
+        EasyMock.reset(httpServletRequestHandler);
+        filter = new 
CorsHttpServletRequestFilter(Arrays.asList(stopRequestProcessing, 
throwException));
+        EasyMock.replay(httpServletRequestHandler);
+        filter.service(request, response, httpServletRequestHandler);
+        EasyMock.verify(httpServletRequestHandler);
+
+
+    }
+    
+    private static final class TestCorsHandler implements CorsHandler
+    {
+        private boolean called;
+
+        public boolean isCalled() {
+            return called;
+        }
+        
+        @Override
+        public CorsHandlerResult handle(HttpServletRequest request, 
HttpServletResponse response) throws IOException {
+            called = true;
+            return CorsHandlerResult.CONTINUE_CORS_PROCESSING;
+        }
+        
+    }
+    
+}
diff --git 
a/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandlerTest.java
 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandlerTest.java
new file mode 100644
index 000000000..e711e4591
--- /dev/null
+++ 
b/tapestry-http/src/test/java/org/apache/tapestry5/http/internal/services/DefaultCorsHandlerTest.java
@@ -0,0 +1,78 @@
+package org.apache.tapestry5.http.internal.services;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.tapestry5.http.CorsHandlerResult;
+import org.apache.tapestry5.http.services.CorsHandlerHelper;
+import org.apache.tapestry5.http.services.CorsHttpServletRequestFilter;
+import org.easymock.EasyMock;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Test suite for {@link CorsHttpServletRequestFilter}.
+ */
+public class DefaultCorsHandlerTest {
+    
+    @Test
+    public void handle() throws IOException
+    {
+        
+        HttpServletRequest request = 
EasyMock.createMock(HttpServletRequest.class);
+        HttpServletResponse response = 
EasyMock.createMock(HttpServletResponse.class);
+        CorsHandlerHelper helper = 
EasyMock.createMock(CorsHandlerHelper.class);
+        DefaultCorsHandler handler = new DefaultCorsHandler(helper);
+        
+        // Scenario 1: no preflight, no origin, not CORS
+        EasyMock.reset(helper, request, response);
+        EasyMock.expect(helper.isPreflight(request)).andReturn(false);
+        
EasyMock.expect(helper.getAllowedOrigin(request)).andReturn(Optional.empty());
+        EasyMock.replay(helper, request, response);
+        Assert.assertEquals(CorsHandlerResult.CONTINUE_CORS_PROCESSING, 
handler.handle(request, response));
+        EasyMock.verify(helper, request, response);
+        
+        // Scenario 2: preflight, no allowed Origin, not CORS
+        EasyMock.reset(helper, request, response);
+        EasyMock.expect(helper.isPreflight(request)).andReturn(true);
+        
EasyMock.expect(helper.getAllowedOrigin(request)).andReturn(Optional.empty());
+        EasyMock.replay(helper, request, response);
+        Assert.assertEquals(CorsHandlerResult.CONTINUE_CORS_PROCESSING, 
handler.handle(request, response));
+        EasyMock.verify(helper, request, response);
+
+        // Scenario 3: preflight, allowed Origin, preflight CORS
+        final String origin = "https://tapestry.apache.org";;
+        EasyMock.reset(helper, request, response);
+        
+        EasyMock.expect(helper.isPreflight(request)).andReturn(true);
+        
EasyMock.expect(helper.getAllowedOrigin(request)).andReturn(Optional.of(origin));
+        helper.configureOrigin(response, origin);
+        helper.configureCredentials(response);
+        helper.configureExposeHeaders(response);
+        helper.configureMethods(response);
+        helper.configureAllowedHeaders(response, request);
+        helper.configureMaxAge(response);
+        response.setContentLength(0);
+        response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+
+        EasyMock.replay(helper, request, response);
+        Assert.assertEquals(CorsHandlerResult.STOP_REQUEST_PROCESSING, 
handler.handle(request, response));
+        EasyMock.verify(helper, request, response);
+        
+        // Scenario 4: no preflight, allowed Origin, non-preflight CORS
+        EasyMock.reset(helper, request, response);
+        EasyMock.expect(helper.isPreflight(request)).andReturn(false);
+        
EasyMock.expect(helper.getAllowedOrigin(request)).andReturn(Optional.of(origin));
+        helper.configureOrigin(response, origin);
+        helper.configureCredentials(response);
+        helper.configureExposeHeaders(response);
+        EasyMock.replay(helper, request, response);
+        Assert.assertEquals(CorsHandlerResult.CONTINUE_CORS_PROCESSING, 
handler.handle(request, response));
+        EasyMock.verify(helper, request, response);
+        
+    }
+    
+}

Reply via email to