Merge pull request #432 from salcho/coop-coep-post

WW-5085: Add Cross-Origin Opener Policy (COOP) and Cross-Origin Embedder Policy (COEP) support
diff --git a/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java
index 753b0f0..9535b3a 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java
@@ -21,12 +21,13 @@
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_DEST_HEADER;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_MODE_HEADER;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_SITE_HEADER;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_USER_HEADER;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.VARY_HEADER;
 
 import com.opensymphony.xwork2.ActionContext;
 import com.opensymphony.xwork2.ActionInvocation;
+import com.opensymphony.xwork2.inject.Inject;
 import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
-import com.opensymphony.xwork2.interceptor.PreResultListener;
 import com.opensymphony.xwork2.util.TextParseUtil;
 import java.util.HashSet;
 import java.util.Set;
@@ -41,17 +42,19 @@
  * filter the requests allowed to be processed.
  *
  * @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
+ * @see <a href="https://www.w3.org/TR/fetch-metadata/">https://www.w3.org/TR/fetch-metadata/</a>
  **/
 
 public class FetchMetadataInterceptor extends AbstractInterceptor {
-    private static final Logger logger = LogManager.getLogger(FetchMetadataInterceptor.class);
-    private static final String VARY_HEADER_VALUE = String.format("%s,%s,%s", SEC_FETCH_DEST_HEADER, SEC_FETCH_SITE_HEADER, SEC_FETCH_MODE_HEADER);
+    private static final Logger LOG = LogManager.getLogger(FetchMetadataInterceptor.class);
+    private static final String VARY_HEADER_VALUE = String.format("%s,%s,%s,%s", SEC_FETCH_DEST_HEADER, SEC_FETCH_MODE_HEADER, SEC_FETCH_SITE_HEADER, SEC_FETCH_USER_HEADER);
     private static final String SC_FORBIDDEN = String.valueOf(HttpServletResponse.SC_FORBIDDEN);
 
     private final Set<String> exemptedPaths = new HashSet<>();
     private final ResourceIsolationPolicy resourceIsolationPolicy = new StrutsResourceIsolationPolicy();
 
-    public void setExemptedPaths(String paths){
+    @Inject (required=false)
+    public void setExemptedPaths(String paths) {
         this.exemptedPaths.addAll(TextParseUtil.commaDelimitedStringToSet(paths));
     }
 
@@ -73,15 +76,31 @@
             return invocation.invoke();
         }
 
-        logger.atDebug().log(
-            "Fetch metadata rejected cross-origin request to %s",
-            contextPath
-        );
+        LOG.info("Fetch metadata rejected cross-origin request to [{}]", contextPath);
         return SC_FORBIDDEN;
     }
 
+    /**
+     * Sets {@link SEC_FETCH_DEST_HEADER}, {@link SEC_FETCH_MODE_HEADER}, {@link SEC_FETCH_SITE_HEADER}, and {@link SEC_FETCH_USER_HEADER}
+     * elements in the provided ActionInvocation's HttpServletResponse {@link VARY_HEADER} response header.
+     * 
+     * Note: This method will replace any previous Vary header content already set for the response.
+     * Note: In order to be effective, the Vary header modification must take place at (or very near) the start of this interceptor's processing.
+     * 
+     * @param invocation  Supplies the HttpServletResponse (if present) to which the SEC_FETCH_* header names are be added to its {@link VARY_HEADER} response header.
+     */
     private void addVaryHeaders(ActionInvocation invocation) {
         HttpServletResponse response = invocation.getInvocationContext().getServletResponse();
-        response.setHeader(VARY_HEADER, VARY_HEADER_VALUE);
+        if (response != null) {
+            // TODO: Whenever servlet 3.x becomes the baseline for Struts, consider revising this method to use
+            //       getHeader(VARY_HEADER) and preserve any VARY_HEADER content already set in the response.
+            //       This will probably require some tokenization logic for the header contents.
+            if (LOG.isDebugEnabled() && response.containsHeader(VARY_HEADER)) {
+                LOG.debug("HTTP response already has a [{}] header set, the old value will be overwritten (replaced)", VARY_HEADER);
+            }
+            response.setHeader(VARY_HEADER, VARY_HEADER_VALUE);
+        } else {
+            LOG.debug("HTTP response is null, cannot add a new [{}] header", VARY_HEADER);
+        }
     }
 }
diff --git a/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java b/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java
index b272914..305b971 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java
@@ -30,24 +30,49 @@
  * See {@link StrutsResourceIsolationPolicy} for the default implementation used.
  *
  * @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
+ * @see <a href="https://www.w3.org/TR/fetch-metadata/">https://www.w3.org/TR/fetch-metadata/</a>
  **/
 
 @FunctionalInterface
 public interface ResourceIsolationPolicy {
-    String SEC_FETCH_SITE_HEADER = "sec-fetch-site";
-    String SEC_FETCH_MODE_HEADER = "sec-fetch-mode";
-    String SEC_FETCH_DEST_HEADER = "sec-fetch-dest";
+    String SEC_FETCH_DEST_HEADER = "Sec-Fetch-Dest";
+    String SEC_FETCH_MODE_HEADER = "Sec-Fetch-Mode";
+    String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site";
+    String SEC_FETCH_USER_HEADER = "Sec-Fetch-User";
     String VARY_HEADER = "Vary";
-    String SAME_ORIGIN = "same-origin";
-    String SAME_SITE = "same-site";
-    String NONE = "none";
-    String MODE_NAVIGATE = "navigate";
-    String DEST_OBJECT = "object";
+    // Valid values for the SEC_FETCH_DEST_HEADER.  Note: The specifications says servers should ignore the header if it contains an invalid value.
+    String DEST_AUDIO = "audio";
+    String DEST_AUDIOWORKLET = "audioworklet";
+    String DEST_DOCUMENT = "document";
     String DEST_EMBED = "embed";
-    String CROSS_SITE = "cross-site";
-    String CORS = "cors";
-    String DEST_SCRIPT = "script";
+    String DEST_EMPTY = "empty";
+    String DEST_FONT = "font";
     String DEST_IMAGE = "image";
+    String DEST_MANIFEST = "manifest";
+    String DEST_NESTED_DOCUMENT = "nested-document";
+    String DEST_OBJECT = "object";
+    String DEST_PAINTWORKLET = "paintworklet";
+    String DEST_REPORT = "report";
+    String DEST_SCRIPT = "script";
+    String DEST_SERVICEWORKER = "serviceworker";
+    String DEST_SHAREDWORKER = "sharedworker";
+    String DEST_STYLE = "style";
+    String DEST_TRACK = "track";
+    String DEST_VIDEO = "video";
+    String DEST_WORKER = "worker";
+    String DEST_XSLT = "xslt";
+    // Valid values for the SEC_FETCH_MODE_HEADER.  Note: The specifications says servers should ignore the header if it contains an invalid value.
+    String MODE_CORS = "cors";
+    String MODE_NAVIGATE = "navigate";
+    String MODE_NESTED_NAVIGATE = "nested-navigate";
+    String MODE_NO_CORS = "no-cors";
+    String MODE_SAME_ORIGIN = "same-origin";
+    String MODE_WEBSOCKET = "websocket";
+     // Valid values for the SEC_FETCH_SITE_HEADER.  Note: The specifications says servers should ignore the header if it contains an invalid value.
+    String SITE_CROSS_SITE = "cross-site";
+    String SITE_SAME_ORIGIN = "same-origin";
+    String SITE_SAME_SITE = "same-site";
+    String SITE_NONE = "none";
 
     boolean isRequestAllowed(HttpServletRequest request);
 }
diff --git a/core/src/main/java/org/apache/struts2/interceptor/StrutsResourceIsolationPolicy.java b/core/src/main/java/org/apache/struts2/interceptor/StrutsResourceIsolationPolicy.java
index ed0281b..24e0e7f 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/StrutsResourceIsolationPolicy.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/StrutsResourceIsolationPolicy.java
@@ -29,6 +29,7 @@
  * <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>.
  *
  * @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
+ * @see <a href="https://www.w3.org/TR/fetch-metadata/">https://www.w3.org/TR/fetch-metadata/</a>
  **/
 
 public final class StrutsResourceIsolationPolicy implements ResourceIsolationPolicy {
@@ -38,12 +39,12 @@
         String site = request.getHeader(SEC_FETCH_SITE_HEADER);
 
         // Allow requests from browsers which don't send Fetch Metadata
-        if (Strings.isEmpty(site)){
+        if (Strings.isEmpty(site)) {
             return true;
         }
 
         // Allow same-site and browser-initiated requests
-        if (SAME_ORIGIN.equals(site) || SAME_SITE.equals(site) || NONE.equals(site)) {
+        if (SITE_SAME_ORIGIN.equalsIgnoreCase(site) || SITE_SAME_SITE.equalsIgnoreCase(site) || SITE_NONE.equalsIgnoreCase(site)) {
             return true;
         }
 
@@ -55,8 +56,8 @@
         String mode = request.getHeader(SEC_FETCH_MODE_HEADER);
         String dest = request.getHeader(SEC_FETCH_DEST_HEADER);
 
-        boolean isSimpleTopLevelNavigation = MODE_NAVIGATE.equals(mode) || "GET".equals(request.getMethod());
-        boolean isNotObjectOrEmbedRequest = !DEST_EMBED.equals(dest) && !DEST_OBJECT.equals(dest);
+        boolean isSimpleTopLevelNavigation = MODE_NAVIGATE.equalsIgnoreCase(mode) || "GET".equalsIgnoreCase(request.getMethod());
+        boolean isNotObjectOrEmbedRequest = !DEST_EMBED.equalsIgnoreCase(dest) && !DEST_OBJECT.equalsIgnoreCase(dest);
 
         return isSimpleTopLevelNavigation && isNotObjectOrEmbedRequest;
     }
diff --git a/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java
index 7bff2d0..2249077 100644
--- a/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java
+++ b/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java
@@ -19,20 +19,37 @@
 package org.apache.struts2.interceptor;
 
 
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.DEST_EMBED;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.DEST_OBJECT;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.DEST_SCRIPT;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.MODE_NAVIGATE;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_DEST_HEADER;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_MODE_HEADER;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_SITE_HEADER;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_USER_HEADER;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SITE_NONE;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SITE_SAME_ORIGIN;
+import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SITE_SAME_SITE;
 import static org.apache.struts2.interceptor.ResourceIsolationPolicy.VARY_HEADER;
 import static org.junit.Assert.assertNotEquals;
 
 import com.opensymphony.xwork2.ActionContext;
 import com.opensymphony.xwork2.XWorkTestCase;
+import com.opensymphony.xwork2.config.RuntimeConfiguration;
+import com.opensymphony.xwork2.config.entities.ActionConfig;
+import com.opensymphony.xwork2.config.entities.InterceptorMapping;
+import com.opensymphony.xwork2.config.entities.InterceptorStackConfig;
+import com.opensymphony.xwork2.config.entities.PackageConfig;
+import com.opensymphony.xwork2.config.providers.XmlConfigurationProvider;
 import com.opensymphony.xwork2.mock.MockActionInvocation;
 import org.apache.struts2.ServletActionContext;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import javax.servlet.http.HttpServletResponse;
 
 public class FetchMetadataInterceptorTest extends XWorkTestCase {
 
@@ -40,12 +57,9 @@
     private final MockActionInvocation mai = new MockActionInvocation();
     private final MockHttpServletRequest request = new MockHttpServletRequest();
     private final MockHttpServletResponse response = new MockHttpServletResponse();
-    private static final String VARY_HEADER_VALUE = String.format(
-        "%s,%s,%s",
-        SEC_FETCH_DEST_HEADER,
-        SEC_FETCH_SITE_HEADER,
-        SEC_FETCH_MODE_HEADER
-    );
+    private static final String ACCEPT_ENCODING_VALUE = "Accept-Encoding";
+    private static final String VARY_HEADER_VALUE = String.format("%s,%s,%s,%s", SEC_FETCH_DEST_HEADER, SEC_FETCH_MODE_HEADER, SEC_FETCH_SITE_HEADER, SEC_FETCH_USER_HEADER);
+    private static final String SC_FORBIDDEN = String.valueOf(HttpServletResponse.SC_FORBIDDEN);
 
     @Override
     protected void setUp() throws Exception {
@@ -59,59 +73,55 @@
     }
 
     public void testNoSite() throws Exception {
-        request.removeHeader("sec-fetch-site");
+        request.removeHeader(SEC_FETCH_SITE_HEADER);
 
-        assertNotEquals("Expected interceptor to accept this request", "403",
-            interceptor.intercept(mai));
+        assertNotEquals("Expected interceptor to accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
     }
 
     public void testValidSite() throws Exception {
-        for (String header : Arrays.asList("same-origin", "same-site", "none")){
-            request.addHeader("sec-fetch-site", header);
+        for (String header : Arrays.asList(SITE_SAME_ORIGIN, SITE_SAME_SITE, SITE_NONE)){
+            request.addHeader(SEC_FETCH_SITE_HEADER, header);
 
-            assertNotEquals("Expected interceptor to accept this request", "403",
-                interceptor.intercept(mai));
+            assertNotEquals("Expected interceptor to accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
         }
 
     }
 
     public void testValidTopLevelNavigation() throws Exception {
-        request.addHeader("sec-fetch-mode", "navigate");
-        request.addHeader("sec-fetch-dest", "script");
+        request.addHeader(SEC_FETCH_MODE_HEADER, MODE_NAVIGATE);
+        request.addHeader(SEC_FETCH_DEST_HEADER, DEST_SCRIPT);
         request.setMethod("GET");
 
-        assertNotEquals("Expected interceptor to accept this request", "403",
-            interceptor.intercept(mai));
+        assertNotEquals("Expected interceptor to accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
     }
 
     public void testInvalidTopLevelNavigation() throws Exception {
-        for (String header : Arrays.asList("object", "embed")) {
-            request.addHeader("sec-fetch-site", "foo");
-            request.addHeader("sec-fetch-mode", "navigate");
-            request.addHeader("sec-fetch-dest", header);
+        for (String header : Arrays.asList(DEST_OBJECT, DEST_EMBED)) {
+            request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
+            request.addHeader(SEC_FETCH_MODE_HEADER, MODE_NAVIGATE);
+            request.addHeader(SEC_FETCH_DEST_HEADER, header);
             request.setMethod("GET");
 
-            assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
+            assertEquals("Expected interceptor to NOT accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
         }
     }
 
     public void testPathInExemptedPaths() throws Exception {
-        request.addHeader("sec-fetch-site", "foo");
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
         request.setContextPath("/foo");
 
-        assertNotEquals("Expected interceptor to accept this request", "403",
-            interceptor.intercept(mai));
+        assertNotEquals("Expected interceptor to accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
     }
 
     public void testPathNotInExemptedPaths() throws Exception {
-        request.addHeader("sec-fetch-site", "foo");
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
         request.setContextPath("/foobar");
 
-        assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
+        assertEquals("Expected interceptor to NOT accept this request", SC_FORBIDDEN, interceptor.intercept(mai));
     }
 
     public void testVaryHeaderAcceptedReq() throws Exception {
-        request.addHeader("sec-fetch-site", "foo");
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
         request.setContextPath("/foo");
 
         interceptor.intercept(mai);
@@ -121,11 +131,132 @@
     }
 
     public void testVaryHeaderRejectedReq() throws Exception {
-        request.addHeader("sec-fetch-site", "foo");
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
 
         interceptor.intercept(mai);
 
         assertTrue("Expected vary header to be included", response.containsHeader(VARY_HEADER));
         assertEquals("Expected different vary header value", response.getHeader(VARY_HEADER), VARY_HEADER_VALUE);
     }
+
+    public void testVaryHeaderReplaced() throws Exception {
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
+        response.addHeader(VARY_HEADER, ACCEPT_ENCODING_VALUE);  // Simulate Vary header present due to processing before this interceptor.
+        assertEquals("Initial vary response header addition failed ?", response.getHeader(VARY_HEADER), ACCEPT_ENCODING_VALUE);
+
+        interceptor.intercept(mai);
+
+        assertTrue("Expected vary header to be included", response.containsHeader(VARY_HEADER));
+        assertFalse("Expected original vary header content to be replaced", response.getHeader(VARY_HEADER).contains(ACCEPT_ENCODING_VALUE));
+        assertTrue("Expected added vary header content to be present", response.getHeader(VARY_HEADER).contains(VARY_HEADER_VALUE));
+    }
+
+    public void testSetExemptedPathsInjectionIndirectly() throws Exception {
+        // Perform a multi-step test to confirm (indirectly) that the method parameter injection of setExemptedPaths() for
+        // the FetchMetadataInterceptor is functioning as expected, when configured appropriately.
+        // Ensure we're using the specific test configuration, not the default simple configuration.
+        XmlConfigurationProvider configurationProvider = new XmlConfigurationProvider("struts-testing.xml");
+        container.inject(configurationProvider);
+        loadConfigurationProviders(configurationProvider);
+
+        // The test configuration in "struts-testing.xml" should define a "default" package.  That "default" package should contain a "defaultInterceptorStack" containing
+        // a "fetchMetadata" interceptor parameter "fetchMetadata.setExemptedPaths".  If the parameter method injection is working correctly for the FetchMetadataInterceptor,
+        // the exempted paths should be set appropriately for the interceptor instances, once the configuration is loaded into the container.
+        final PackageConfig defaultPackageConfig = configuration.getPackageConfig("default");
+        final InterceptorStackConfig defaultInterceptorStackConfig = (InterceptorStackConfig) defaultPackageConfig.getInterceptorConfig("defaultInterceptorStack");
+        final Collection<InterceptorMapping> defaultInterceptorStackInterceptors = defaultInterceptorStackConfig.getInterceptors();
+        assertFalse("'defaultInterceptorStack' interceptors in struts-testing.xml is empty ?", defaultInterceptorStackInterceptors.isEmpty());
+        InterceptorMapping configuredFetchMetadataInterceptorMapping = null;
+        Iterator<InterceptorMapping> interceptorIterator = defaultInterceptorStackInterceptors.iterator();
+        while (interceptorIterator.hasNext()) {
+            InterceptorMapping currentMapping = interceptorIterator.next();
+            if (currentMapping != null && "fetchMetadata".equals(currentMapping.getName())) {
+                configuredFetchMetadataInterceptorMapping = currentMapping;
+                break;
+            }
+        }
+        assertNotNull("'fetchMetadata' interceptor mapping not present after loading 'struts-testing.xml' ?", configuredFetchMetadataInterceptorMapping);
+        assertTrue("'fetchMetadata' interceptor mapping loaded from 'struts-testing.xml' produced a non-FetchMetadataInterceptor type ?", configuredFetchMetadataInterceptorMapping.getInterceptor() instanceof FetchMetadataInterceptor);
+        FetchMetadataInterceptor configuredFetchMetadataInterceptor = (FetchMetadataInterceptor) configuredFetchMetadataInterceptorMapping.getInterceptor();
+        request.removeHeader(SEC_FETCH_SITE_HEADER);
+        request.addHeader(SEC_FETCH_SITE_HEADER, "foo");
+        request.setContextPath("/foo");
+        assertEquals("Expected interceptor to NOT accept this request [/foo]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+        request.setContextPath("/fetchMetadataExemptedGlobal");
+        assertNotEquals("Expected interceptor to accept this request [/fetchMetadataExemptedGlobal]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+        request.setContextPath("/someOtherPath");
+        assertNotEquals("Expected interceptor to accept this request [/someOtherPath]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+
+        // The test configuration in "struts-testing.xml" should also contain three actions configured differently for the "fetchMetadata" interceptor.
+        // "fetchMetadataExempted" has an override exemption matching its action name, "fetchMetadataNotExempted" has an override exemption NOT matching its action name,
+        // and "fetchMetadataExemptedGlobal" has an action name matching an exemption defined in "defaultInterceptorStack".
+        final RuntimeConfiguration runtimeConfiguration = configuration.getRuntimeConfiguration();
+        final ActionConfig fetchMetadataExemptedActionConfig = runtimeConfiguration.getActionConfig("/", "fetchMetadataExempted");
+        final ActionConfig fetchMetadataNotExemptedActionConfig = runtimeConfiguration.getActionConfig("/", "fetchMetadataNotExempted");
+        final ActionConfig fetchMetadataExemptedGlobalActionConfig = runtimeConfiguration.getActionConfig("/", "fetchMetadataExemptedGlobal");
+        assertNotNull("'fetchMetadataExempted' action config not present in 'struts-testing.xml' ?", fetchMetadataExemptedActionConfig);
+        assertNotNull("'fetchMetadataNotExempted' action config not present in 'struts-testing.xml' ?", fetchMetadataExemptedActionConfig);
+        assertNotNull("'fetchMetadataExemptedGlobal' action config not present in 'struts-testing.xml' ?", fetchMetadataExemptedActionConfig);
+
+        // Test fetchMetadata interceptor for the "fetchMetadataExempted" action.
+        Collection<InterceptorMapping> currentActionInterceptors = fetchMetadataExemptedActionConfig.getInterceptors();
+        assertFalse("'fetchMetadataExempted' interceptors in struts-testing.xml is empty ?", currentActionInterceptors.isEmpty());
+        configuredFetchMetadataInterceptorMapping = null;
+        interceptorIterator = currentActionInterceptors.iterator();
+        while (interceptorIterator.hasNext()) {
+            InterceptorMapping currentMapping = interceptorIterator.next();
+            if (currentMapping != null && "fetchMetadata".equals(currentMapping.getName())) {
+                configuredFetchMetadataInterceptorMapping = currentMapping;
+                break;
+            }
+        }
+        assertNotNull("'fetchMetadata' interceptor mapping for action 'fetchMetadataExempted' not present in 'struts-testing.xml' ?", configuredFetchMetadataInterceptorMapping);
+        assertTrue("'fetchMetadata' interceptor mapping for action 'fetchMetadataExempted' in 'struts-testing.xml' produced a non-FetchMetadataInterceptor type ?", configuredFetchMetadataInterceptorMapping.getInterceptor() instanceof FetchMetadataInterceptor);
+        configuredFetchMetadataInterceptor = (FetchMetadataInterceptor) configuredFetchMetadataInterceptorMapping.getInterceptor();
+        request.removeHeader(SEC_FETCH_SITE_HEADER);
+        request.addHeader(SEC_FETCH_SITE_HEADER, fetchMetadataExemptedActionConfig.getName());
+        request.setContextPath("/" + fetchMetadataExemptedActionConfig.getName());
+        assertNotEquals("Expected interceptor to accept this request [" + "/" + fetchMetadataExemptedActionConfig.getName() + "]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+
+        // Test fetchMetadata interceptor for the "fetchMetadataNotExempted" action.
+        currentActionInterceptors = fetchMetadataNotExemptedActionConfig.getInterceptors();
+        assertFalse("'fetchMetadataNotExempted' interceptors in struts-testing.xml is empty ?", currentActionInterceptors.isEmpty());
+        configuredFetchMetadataInterceptorMapping = null;
+        interceptorIterator = currentActionInterceptors.iterator();
+        while (interceptorIterator.hasNext()) {
+            InterceptorMapping currentMapping = interceptorIterator.next();
+            if (currentMapping != null && "fetchMetadata".equals(currentMapping.getName())) {
+                configuredFetchMetadataInterceptorMapping = currentMapping;
+                break;
+            }
+        }
+        assertNotNull("'fetchMetadata' interceptor mapping for action 'fetchMetadataNotExempted' not present in 'struts-testing.xml' ?", configuredFetchMetadataInterceptorMapping);
+        assertTrue("'fetchMetadata' interceptor mapping 'fetchMetadataExempted' in 'struts-testing.xml' produced a non-FetchMetadataInterceptor type ?", configuredFetchMetadataInterceptorMapping.getInterceptor() instanceof FetchMetadataInterceptor);
+        configuredFetchMetadataInterceptor = (FetchMetadataInterceptor) configuredFetchMetadataInterceptorMapping.getInterceptor();
+        request.removeHeader(SEC_FETCH_SITE_HEADER);
+        request.addHeader(SEC_FETCH_SITE_HEADER, fetchMetadataNotExemptedActionConfig.getName());
+        request.setContextPath("/" + fetchMetadataNotExemptedActionConfig.getName());
+        assertEquals("Expected interceptor to NOT accept this request [" + "/" + fetchMetadataNotExemptedActionConfig.getName() + "]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+
+        // Test fetchMetadata interceptor for the "fetchMetadataExemptedGlobal" action.
+        currentActionInterceptors = fetchMetadataExemptedGlobalActionConfig.getInterceptors();
+        assertFalse("'fetchMetadataExemptedGlobal' interceptors in struts-testing.xml is empty ?", currentActionInterceptors.isEmpty());
+        configuredFetchMetadataInterceptorMapping = null;
+        interceptorIterator = currentActionInterceptors.iterator();
+        while (interceptorIterator.hasNext()) {
+            InterceptorMapping currentMapping = interceptorIterator.next();
+            if (currentMapping != null && "fetchMetadata".equals(currentMapping.getName())) {
+                configuredFetchMetadataInterceptorMapping = currentMapping;
+                break;
+            }
+        }
+        assertNotNull("'fetchMetadata' interceptor mapping for action 'fetchMetadataExemptedGlobal' not present in 'struts-testing.xml' ?", configuredFetchMetadataInterceptorMapping);
+        assertTrue("'fetchMetadata' interceptor mapping 'fetchMetadataExemptedGlobal' in 'struts-testing.xml' produced a non-FetchMetadataInterceptor type ?", configuredFetchMetadataInterceptorMapping.getInterceptor() instanceof FetchMetadataInterceptor);
+        configuredFetchMetadataInterceptor = (FetchMetadataInterceptor) configuredFetchMetadataInterceptorMapping.getInterceptor();
+        request.removeHeader(SEC_FETCH_SITE_HEADER);
+        request.addHeader(SEC_FETCH_SITE_HEADER, fetchMetadataExemptedGlobalActionConfig.getName());
+        request.setContextPath("/" + fetchMetadataExemptedGlobalActionConfig.getName());
+        assertNotEquals("Expected interceptor to accept this request [" + "/" + fetchMetadataExemptedGlobalActionConfig.getName() + "]", SC_FORBIDDEN, configuredFetchMetadataInterceptor.intercept(mai));
+    }
+
 }
diff --git a/core/src/test/resources/struts-testing.xml b/core/src/test/resources/struts-testing.xml
new file mode 100644
index 0000000..5d9c5b2
--- /dev/null
+++ b/core/src/test/resources/struts-testing.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * 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.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+          "-//Apache Software Foundation//DTD Struts Configuration 2.5//EN"
+          "http://struts.apache.org/dtds/struts-2.5.dtd">
+<struts>
+    <include file="struts-default.xml"/>
+    <package name="default" extends="struts-default" namespace="/">
+        <interceptors>
+            <interceptor-stack name="defaultInterceptorStack">
+                <interceptor-ref name="defaultStack">
+                    <param name="fetchMetadata.setExemptedPaths">/fetchMetadataExemptedGlobal,/someOtherPath</param>
+                </interceptor-ref>
+            </interceptor-stack>
+        </interceptors>
+
+        <action name="fetchMetadataExempted" class="com.opensymphony.xwork2.SimpleAction">
+            <interceptor-ref name="defaultInterceptorStack">
+               <param name="fetchMetadata.setExemptedPaths">/fetchMetadataExempted</param>
+            </interceptor-ref>
+            <result name="success">hello.jsp</result>
+        </action>
+
+        <action name="fetchMetadataNotExempted" class="com.opensymphony.xwork2.SimpleAction">
+            <interceptor-ref name="defaultInterceptorStack">
+                <param name="fetchMetadata.setExemptedPaths">/nonMatchingActionPath</param>
+            </interceptor-ref>
+            <result name="success">hello.jsp</result>
+        </action>
+
+        <action name="fetchMetadataExemptedGlobal" class="com.opensymphony.xwork2.SimpleAction">
+            <interceptor-ref name="defaultInterceptorStack" />
+            <result name="success">hello.jsp</result>
+        </action>
+    </package>
+</struts>