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);
+ }
+
+}