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/CoepInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/CoepInterceptor.java
new file mode 100644
index 0000000..1a3dfa2
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/CoepInterceptor.java
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+package org.apache.struts2.interceptor;
+
+import com.opensymphony.xwork2.ActionInvocation;
+import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
+import com.opensymphony.xwork2.interceptor.PreResultListener;
+import com.opensymphony.xwork2.util.TextParseUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+
+/**
+ * Interceptor that implements Cross-Origin Embedder Policy on incoming requests used to protect a
+ * document from loading any non-same-origin resources which don't explicitly grant the document
+ * permission to be loaded.
+ *
+ *
+ * @see <a href="https://web.dev/why-coop-coep/#coep">https://web.dev/why-coop-coep/#coep</a>
+ * @see <a href="https://wicg.github.io/cross-origin-embedder-policy/">https://wicg.github.io/cross-origin-embedder-policy/</a>
+ **/
+public class CoepInterceptor extends AbstractInterceptor implements PreResultListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(CoepInterceptor.class);
+    private static final String REQUIRE_COEP_HEADER = "require-corp";
+    private static final String COEP_ENFORCING_HEADER = "Cross-Origin-Embedder-Policy";
+    private static final String COEP_REPORT_HEADER = "Cross-Origin-Embedder-Policy-Report-Only";
+
+    private final Set<String> exemptedPaths = new HashSet<>();
+    private boolean disabled = false;
+    private String header = COEP_ENFORCING_HEADER;
+
+    @Override
+    public String intercept(ActionInvocation invocation) throws Exception {
+        invocation.addPreResultListener(this);
+        return invocation.invoke();
+    }
+
+    @Override
+    public void beforeResult(ActionInvocation invocation, String resultCode) {
+        HttpServletRequest req = invocation.getInvocationContext().getServletRequest();
+        HttpServletResponse res = invocation.getInvocationContext().getServletResponse();
+        final String path = req.getContextPath();
+
+        if (exemptedPaths.contains(path)){
+            // no need to add headers
+            LOG.debug(String.format("Skipping COEP header for exempted path %s", path));
+        } else if (!disabled){
+            res.setHeader(header, REQUIRE_COEP_HEADER);
+        }
+    }
+
+    public void setExemptedPaths(String paths){
+        this.exemptedPaths.addAll(TextParseUtil.commaDelimitedStringToSet(paths));
+    }
+
+    public void setEnforcingMode(String mode){
+        boolean enforcingMode = Boolean.parseBoolean(mode);
+        if (enforcingMode){
+            header = COEP_ENFORCING_HEADER;
+        } else {
+            header = COEP_REPORT_HEADER;
+        }
+    }
+
+    public void setDisabled(String value){
+        disabled = Boolean.parseBoolean(value);
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/interceptor/CoopInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/CoopInterceptor.java
new file mode 100644
index 0000000..9cf6b2e
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/CoopInterceptor.java
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+package org.apache.struts2.interceptor;
+
+import com.opensymphony.xwork2.ActionInvocation;
+import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
+import com.opensymphony.xwork2.interceptor.PreResultListener;
+import com.opensymphony.xwork2.util.TextParseUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.HashSet;
+import java.util.Set;
+
+
+/**
+ * Interceptor that implements Cross-Origin Opener Policy on incoming requests. COOP is a mitigation against
+ * cross-origin information leaks and is used to make websites, cross-origin isolated. Setting the COOP header allows you to ensure that a top-level window is
+ * isolated from other documents by putting them in a different browsing context group, so they
+ * cannot directly interact with the top-level window.
+ *
+ * @see <a href="https://web.dev/why-coop-coep/#coop">https://web.dev/why-coop-coep/#coop</a>
+ * @see <a href="https://github.com/whatwg/html/pull/5334/files">https://github.com/whatwg/html/pull/5334/files</a>
+ **/
+public class CoopInterceptor extends AbstractInterceptor implements PreResultListener {
+
+    private static final Logger LOG = LoggerFactory.getLogger(CoopInterceptor.class);
+    private static final String SAME_ORIGIN = "same-origin";
+    private static final String SAME_ORIGIN_ALLOW_POPUPS = "same-origin-allow-popups";
+    private static final String UNSAFE_NONE = "unsafe-none";
+    private static final String COOP_HEADER = "Cross-Origin-Opener-Policy";
+
+    private final Set<String> exemptedPaths = new HashSet<>();
+    private String mode = SAME_ORIGIN;
+
+    @Override
+    public String intercept(ActionInvocation invocation) throws Exception {
+        invocation.addPreResultListener(this);
+        return invocation.invoke();
+    }
+
+    @Override
+    public void beforeResult(ActionInvocation invocation, String resultCode) {
+        HttpServletRequest request = invocation.getInvocationContext().getServletRequest();
+        HttpServletResponse response = invocation.getInvocationContext().getServletResponse();
+        String path = request.getContextPath();
+
+        if (isExempted(path)){
+            // no need to add headers
+            LOG.debug(String.format("Skipping COOP header for exempted path %s", path));
+        } else {
+            response.setHeader(COOP_HEADER, getMode());
+        }
+    }
+
+    public boolean isExempted(String path){
+        return exemptedPaths.contains(path);
+    }
+
+    public void setExemptedPaths(String paths){
+        exemptedPaths.addAll(TextParseUtil.commaDelimitedStringToSet(paths));
+    }
+
+    private String getMode(){
+        return mode;
+    }
+
+    public void setMode(String mode) {
+        if (!(mode.equals(SAME_ORIGIN) || mode.equals(SAME_ORIGIN_ALLOW_POPUPS) || mode.equals(UNSAFE_NONE))){
+            throw new IllegalArgumentException(String.format("Mode '%s' not recognized!", mode));
+        }
+        this.mode = mode;
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/resources/struts-default.xml b/core/src/main/resources/struts-default.xml
index 8f00459..44a6ad8 100644
--- a/core/src/main/resources/struts-default.xml
+++ b/core/src/main/resources/struts-default.xml
@@ -241,10 +241,12 @@
             <interceptor name="alias" class="com.opensymphony.xwork2.interceptor.AliasInterceptor"/>
             <interceptor name="autowiring" class="com.opensymphony.xwork2.spring.interceptor.ActionAutowiringInterceptor"/>
             <interceptor name="chain" class="com.opensymphony.xwork2.interceptor.ChainingInterceptor"/>
+            <interceptor name="coepInterceptor" class="org.apache.struts2.interceptor.CoepInterceptor"/>
             <interceptor name="conversionError" class="org.apache.struts2.interceptor.StrutsConversionErrorInterceptor"/>
             <interceptor name="cookie" class="org.apache.struts2.interceptor.CookieInterceptor"/>
             <interceptor name="cookieProvider" class="org.apache.struts2.interceptor.CookieProviderInterceptor"/>
             <interceptor name="clearSession" class="org.apache.struts2.interceptor.ClearSessionInterceptor" />
+            <interceptor name="coopInterceptor" class="org.apache.struts2.interceptor.CoopInterceptor"/>
             <interceptor name="createSession" class="org.apache.struts2.interceptor.CreateSessionInterceptor" />
             <interceptor name="debugging" class="org.apache.struts2.interceptor.debugging.DebuggingInterceptor" />
             <interceptor name="execAndWait" class="org.apache.struts2.interceptor.ExecuteAndWaitInterceptor"/>
@@ -389,6 +391,15 @@
                 <interceptor-ref name="actionMappingParams"/>
                 <interceptor-ref name="params"/>
                 <interceptor-ref name="conversionError"/>
+                <interceptor-ref name="coepInterceptor">
+                    <param name="enforcingMode">false</param>
+                    <param name="disabled">false</param>
+                    <param name="exemptedPaths"></param>
+                </interceptor-ref>
+                <interceptor-ref name="coopInterceptor">
+                    <param name="exemptedPaths"></param>
+                    <param name="mode">same-origin</param>
+                </interceptor-ref>
                 <interceptor-ref name="fetchMetadata"/>
                 <interceptor-ref name="validation">
                     <param name="excludeMethods">input,back,cancel,browse</param>
diff --git a/core/src/test/java/org/apache/struts2/interceptor/CoepInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/CoepInterceptorTest.java
new file mode 100644
index 0000000..1401951
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/interceptor/CoepInterceptorTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+package org.apache.struts2.interceptor;
+
+import com.opensymphony.xwork2.ActionContext;
+import com.opensymphony.xwork2.mock.MockActionInvocation;
+import org.apache.logging.log4j.util.Strings;
+import org.apache.struts2.ServletActionContext;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class CoepInterceptorTest extends StrutsInternalTestCase {
+
+    private final CoepInterceptor interceptor = new CoepInterceptor();
+    private final MockActionInvocation mai = new MockActionInvocation();
+    private final MockHttpServletRequest request = new MockHttpServletRequest();
+    private final MockHttpServletResponse response = new MockHttpServletResponse();
+
+    private final String COEP_ENFORCING_HEADER = "Cross-Origin-Embedder-Policy";
+    private final String COEP_REPORT_HEADER = "Cross-Origin-Embedder-Policy-Report-Only";
+    private final String HEADER_CONTENT = "require-corp";
+
+
+    public void testDisabled() throws Exception {
+        interceptor.setDisabled("true");
+
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COEP_ENFORCING_HEADER);
+        assertTrue("COEP is not disabled", Strings.isEmpty(header));
+    }
+
+    public void testEnforcingHeader() throws Exception {
+        interceptor.setEnforcingMode("true");
+
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COEP_ENFORCING_HEADER);
+        assertFalse("COEP enforcing header does not exist", Strings.isEmpty(header));
+        assertEquals("COEP header value is incorrect", HEADER_CONTENT, header);
+    }
+
+    public void testExemptedPath() throws Exception{
+        request.setContextPath("/foo");
+        interceptor.setEnforcingMode("true");
+
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COEP_ENFORCING_HEADER);
+        assertTrue("COEP applied to exempted path", Strings.isEmpty(header));
+    }
+
+    public void testReportingHeader() throws Exception {
+        interceptor.setEnforcingMode("false");
+
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COEP_REPORT_HEADER);
+        assertFalse("COEP reporting header does not exist", Strings.isEmpty(header));
+        assertEquals("COEP header value is incorrect", HEADER_CONTENT, header);
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        container.inject(interceptor);
+        interceptor.setExemptedPaths("/foo");
+        ServletActionContext.setRequest(request);
+        ServletActionContext.setResponse(response);
+        ActionContext context = ServletActionContext.getActionContext();
+        Map<String, Object> session = new HashMap<>();
+        context.withSession(session);
+        mai.setInvocationContext(context);
+    }
+
+}
diff --git a/core/src/test/java/org/apache/struts2/interceptor/CoopInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/CoopInterceptorTest.java
new file mode 100644
index 0000000..856a433
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/interceptor/CoopInterceptorTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+package org.apache.struts2.interceptor;
+
+import com.opensymphony.xwork2.ActionContext;
+import com.opensymphony.xwork2.mock.MockActionInvocation;
+import org.apache.logging.log4j.util.Strings;
+import org.apache.struts2.ServletActionContext;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+public class CoopInterceptorTest extends StrutsInternalTestCase {
+
+    private final CoopInterceptor interceptor = new CoopInterceptor();
+    private final MockActionInvocation mai = new MockActionInvocation();
+    private final MockHttpServletRequest request = new MockHttpServletRequest();
+    private final MockHttpServletResponse response = new MockHttpServletResponse();
+
+    String SAME_ORIGIN = "same-origin";
+    String SAME_SITE = "same-site";
+    String UNSAFE_NONE = "unsafe-none";
+    String COOP_HEADER = "Cross-Origin-Opener-Policy";
+
+    public void testHeaderIsSetNonExemptedPath() throws Exception {
+        request.setContextPath("/some");
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COOP_HEADER);
+        assertFalse("Coop header does not existing non-exempted path", Strings.isEmpty(header));
+        assertEquals("Coop header is not same-origin", SAME_ORIGIN, header);
+    }
+
+    public void testHeaderIsNotSetExemptedPath() throws Exception {
+        request.setContextPath("/foo");
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COOP_HEADER);
+        assertTrue("Coop header exists in exempted path", Strings.isEmpty(header));
+    }
+
+    public void testChangeDefaultMode() throws Exception {
+        interceptor.setMode("unsafe-none");
+        request.setContextPath("/some");
+        interceptor.intercept(mai);
+
+        String header = response.getHeader(COOP_HEADER);
+        assertFalse("Coop header does not existin non-exempted path", Strings.isEmpty(header));
+        assertEquals("Coop header is not same-origin", UNSAFE_NONE, header);
+    }
+
+    public void testErrorNotRecognizedMode() throws Exception {
+        request.setContextPath("/some");
+
+        try{
+            interceptor.setMode("foobar");
+            fail("Exception should be thrown for unrecognized mode");
+        } catch (IllegalArgumentException e){
+            assert(true);
+        }
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        container.inject(interceptor);
+        interceptor.setExemptedPaths("/foo,/bar");
+        ServletActionContext.setRequest(request);
+        ServletActionContext.setResponse(response);
+        ActionContext context = ServletActionContext.getActionContext();
+        mai.setInvocationContext(context);
+    }
+
+}