Merge pull request #426 from salcho/post-ww-5083
WW-5083: Adds support for Fetch Metadata in Struts2.
diff --git a/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java
new file mode 100644
index 0000000..753b0f0
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/FetchMetadataInterceptor.java
@@ -0,0 +1,87 @@
+/*
+ * 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 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.VARY_HEADER;
+
+import com.opensymphony.xwork2.ActionContext;
+import com.opensymphony.xwork2.ActionInvocation;
+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;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * Interceptor that implements Fetch Metadata policy on incoming requests used to protect against
+ * CSRF, XSSI, and cross-origin information leaks. Uses {@link StrutsResourceIsolationPolicy} to
+ * filter the requests allowed to be processed.
+ *
+ * @see <a href="https://web.dev/fetch-metadata/">https://web.dev/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 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){
+ this.exemptedPaths.addAll(TextParseUtil.commaDelimitedStringToSet(paths));
+ }
+
+ @Override
+ public String intercept(ActionInvocation invocation) throws Exception {
+ ActionContext context = invocation.getInvocationContext();
+ HttpServletRequest request = context.getServletRequest();
+
+ addVaryHeaders(invocation);
+
+ String contextPath = request.getContextPath();
+ // Apply exemptions: paths/endpoints meant to be served cross-origin
+ if (exemptedPaths.contains(contextPath)) {
+ return invocation.invoke();
+ }
+
+ // Check if request is allowed
+ if (resourceIsolationPolicy.isRequestAllowed(request)) {
+ return invocation.invoke();
+ }
+
+ logger.atDebug().log(
+ "Fetch metadata rejected cross-origin request to %s",
+ contextPath
+ );
+ return SC_FORBIDDEN;
+ }
+
+ private void addVaryHeaders(ActionInvocation invocation) {
+ HttpServletResponse response = invocation.getInvocationContext().getServletResponse();
+ response.setHeader(VARY_HEADER, VARY_HEADER_VALUE);
+ }
+}
diff --git a/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java b/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java
new file mode 100644
index 0000000..b272914
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/ResourceIsolationPolicy.java
@@ -0,0 +1,53 @@
+/*
+ * 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 javax.servlet.http.HttpServletRequest;
+
+/**
+ * Interface for the resource isolation policies to be used for fetch metadata checks.
+ *
+ * Resource isolation policies are designed to protect against cross origin attacks and use the
+ * {@code sec-fetch-*} request headers to decide whether to accept or reject a request. Read more
+ * about <a href="https://web.dev/fetch-metadata/">Fetch Metadata.</a>
+ *
+ * See {@link StrutsResourceIsolationPolicy} for the default implementation used.
+ *
+ * @see <a href="https://web.dev/fetch-metadata/">https://web.dev/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 VARY_HEADER = "Vary";
+ String SAME_ORIGIN = "same-origin";
+ String SAME_SITE = "same-site";
+ String NONE = "none";
+ String MODE_NAVIGATE = "navigate";
+ String DEST_OBJECT = "object";
+ String DEST_EMBED = "embed";
+ String CROSS_SITE = "cross-site";
+ String CORS = "cors";
+ String DEST_SCRIPT = "script";
+ String DEST_IMAGE = "image";
+
+ 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
new file mode 100644
index 0000000..ed0281b
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/interceptor/StrutsResourceIsolationPolicy.java
@@ -0,0 +1,63 @@
+/*
+ * 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 org.apache.logging.log4j.util.Strings;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ *
+ * Default resource isolation policy used in {@link FetchMetadataInterceptor} that
+ * implements the {@link ResourceIsolationPolicy} interface. This default policy is based on
+ * <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>
+ **/
+
+public final class StrutsResourceIsolationPolicy implements ResourceIsolationPolicy {
+
+ @Override
+ public boolean isRequestAllowed(HttpServletRequest request) {
+ String site = request.getHeader(SEC_FETCH_SITE_HEADER);
+
+ // Allow requests from browsers which don't send Fetch Metadata
+ 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)) {
+ return true;
+ }
+
+ // Allow simple top-level navigations except <object> and <embed>
+ return isAllowedTopLevelNavigation(request);
+ }
+
+ private boolean isAllowedTopLevelNavigation(HttpServletRequest request) {
+ 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);
+
+ return isSimpleTopLevelNavigation && isNotObjectOrEmbedRequest;
+ }
+}
diff --git a/core/src/main/resources/struts-default.xml b/core/src/main/resources/struts-default.xml
index 0988d87..8f00459 100644
--- a/core/src/main/resources/struts-default.xml
+++ b/core/src/main/resources/struts-default.xml
@@ -273,6 +273,7 @@
<interceptor name="annotationParameterFilter" class="com.opensymphony.xwork2.interceptor.annotations.AnnotationParameterFilterInterceptor" />
<interceptor name="multiselect" class="org.apache.struts2.interceptor.MultiselectInterceptor" />
<interceptor name="noop" class="org.apache.struts2.interceptor.NoOpInterceptor" />
+ <interceptor name="fetchMetadata" class="org.apache.struts2.interceptor.FetchMetadataInterceptor" />
<!-- Empty stack - performs no operations -->
<interceptor-stack name="emptyStack">
@@ -388,6 +389,7 @@
<interceptor-ref name="actionMappingParams"/>
<interceptor-ref name="params"/>
<interceptor-ref name="conversionError"/>
+ <interceptor-ref name="fetchMetadata"/>
<interceptor-ref name="validation">
<param name="excludeMethods">input,back,cancel,browse</param>
</interceptor-ref>
diff --git a/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java
new file mode 100644
index 0000000..7bff2d0
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/interceptor/FetchMetadataInterceptorTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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 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.VARY_HEADER;
+import static org.junit.Assert.assertNotEquals;
+
+import com.opensymphony.xwork2.ActionContext;
+import com.opensymphony.xwork2.XWorkTestCase;
+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;
+
+public class FetchMetadataInterceptorTest extends XWorkTestCase {
+
+ private final FetchMetadataInterceptor interceptor = new FetchMetadataInterceptor();
+ 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
+ );
+
+ @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);
+ }
+
+ public void testNoSite() throws Exception {
+ request.removeHeader("sec-fetch-site");
+
+ assertNotEquals("Expected interceptor to accept this request", "403",
+ interceptor.intercept(mai));
+ }
+
+ public void testValidSite() throws Exception {
+ for (String header : Arrays.asList("same-origin", "same-site", "none")){
+ request.addHeader("sec-fetch-site", header);
+
+ assertNotEquals("Expected interceptor to accept this request", "403",
+ interceptor.intercept(mai));
+ }
+
+ }
+
+ public void testValidTopLevelNavigation() throws Exception {
+ request.addHeader("sec-fetch-mode", "navigate");
+ request.addHeader("sec-fetch-dest", "script");
+ request.setMethod("GET");
+
+ assertNotEquals("Expected interceptor to accept this request", "403",
+ 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);
+ request.setMethod("GET");
+
+ assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
+ }
+ }
+
+ public void testPathInExemptedPaths() throws Exception {
+ request.addHeader("sec-fetch-site", "foo");
+ request.setContextPath("/foo");
+
+ assertNotEquals("Expected interceptor to accept this request", "403",
+ interceptor.intercept(mai));
+ }
+
+ public void testPathNotInExemptedPaths() throws Exception {
+ request.addHeader("sec-fetch-site", "foo");
+ request.setContextPath("/foobar");
+
+ assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
+ }
+
+ public void testVaryHeaderAcceptedReq() throws Exception {
+ request.addHeader("sec-fetch-site", "foo");
+ request.setContextPath("/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 testVaryHeaderRejectedReq() throws Exception {
+ request.addHeader("sec-fetch-site", "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);
+ }
+}