Skip to content

Commit a55e9ed

Browse files
authored
Merge pull request apache#426 from salcho/post-ww-5083
WW-5083: Adds support for Fetch Metadata in Struts2.
2 parents c2b09d7 + 8902a9a commit a55e9ed

File tree

5 files changed

+336
-0
lines changed

5 files changed

+336
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.interceptor;
20+
21+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_DEST_HEADER;
22+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_MODE_HEADER;
23+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_SITE_HEADER;
24+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.VARY_HEADER;
25+
26+
import com.opensymphony.xwork2.ActionContext;
27+
import com.opensymphony.xwork2.ActionInvocation;
28+
import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
29+
import com.opensymphony.xwork2.interceptor.PreResultListener;
30+
import com.opensymphony.xwork2.util.TextParseUtil;
31+
import java.util.HashSet;
32+
import java.util.Set;
33+
import javax.servlet.http.HttpServletRequest;
34+
import javax.servlet.http.HttpServletResponse;
35+
import org.apache.logging.log4j.LogManager;
36+
import org.apache.logging.log4j.Logger;
37+
38+
/**
39+
* Interceptor that implements Fetch Metadata policy on incoming requests used to protect against
40+
* CSRF, XSSI, and cross-origin information leaks. Uses {@link StrutsResourceIsolationPolicy} to
41+
* filter the requests allowed to be processed.
42+
*
43+
* @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
44+
**/
45+
46+
public class FetchMetadataInterceptor extends AbstractInterceptor {
47+
private static final Logger logger = LogManager.getLogger(FetchMetadataInterceptor.class);
48+
private static final String VARY_HEADER_VALUE = String.format("%s,%s,%s", SEC_FETCH_DEST_HEADER, SEC_FETCH_SITE_HEADER, SEC_FETCH_MODE_HEADER);
49+
private static final String SC_FORBIDDEN = String.valueOf(HttpServletResponse.SC_FORBIDDEN);
50+
51+
private final Set<String> exemptedPaths = new HashSet<>();
52+
private final ResourceIsolationPolicy resourceIsolationPolicy = new StrutsResourceIsolationPolicy();
53+
54+
public void setExemptedPaths(String paths){
55+
this.exemptedPaths.addAll(TextParseUtil.commaDelimitedStringToSet(paths));
56+
}
57+
58+
@Override
59+
public String intercept(ActionInvocation invocation) throws Exception {
60+
ActionContext context = invocation.getInvocationContext();
61+
HttpServletRequest request = context.getServletRequest();
62+
63+
addVaryHeaders(invocation);
64+
65+
String contextPath = request.getContextPath();
66+
// Apply exemptions: paths/endpoints meant to be served cross-origin
67+
if (exemptedPaths.contains(contextPath)) {
68+
return invocation.invoke();
69+
}
70+
71+
// Check if request is allowed
72+
if (resourceIsolationPolicy.isRequestAllowed(request)) {
73+
return invocation.invoke();
74+
}
75+
76+
logger.atDebug().log(
77+
"Fetch metadata rejected cross-origin request to %s",
78+
contextPath
79+
);
80+
return SC_FORBIDDEN;
81+
}
82+
83+
private void addVaryHeaders(ActionInvocation invocation) {
84+
HttpServletResponse response = invocation.getInvocationContext().getServletResponse();
85+
response.setHeader(VARY_HEADER, VARY_HEADER_VALUE);
86+
}
87+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.interceptor;
20+
21+
import javax.servlet.http.HttpServletRequest;
22+
23+
/**
24+
* Interface for the resource isolation policies to be used for fetch metadata checks.
25+
*
26+
* Resource isolation policies are designed to protect against cross origin attacks and use the
27+
* {@code sec-fetch-*} request headers to decide whether to accept or reject a request. Read more
28+
* about <a href="https://web.dev/fetch-metadata/">Fetch Metadata.</a>
29+
*
30+
* See {@link StrutsResourceIsolationPolicy} for the default implementation used.
31+
*
32+
* @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
33+
**/
34+
35+
@FunctionalInterface
36+
public interface ResourceIsolationPolicy {
37+
String SEC_FETCH_SITE_HEADER = "sec-fetch-site";
38+
String SEC_FETCH_MODE_HEADER = "sec-fetch-mode";
39+
String SEC_FETCH_DEST_HEADER = "sec-fetch-dest";
40+
String VARY_HEADER = "Vary";
41+
String SAME_ORIGIN = "same-origin";
42+
String SAME_SITE = "same-site";
43+
String NONE = "none";
44+
String MODE_NAVIGATE = "navigate";
45+
String DEST_OBJECT = "object";
46+
String DEST_EMBED = "embed";
47+
String CROSS_SITE = "cross-site";
48+
String CORS = "cors";
49+
String DEST_SCRIPT = "script";
50+
String DEST_IMAGE = "image";
51+
52+
boolean isRequestAllowed(HttpServletRequest request);
53+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.interceptor;
20+
21+
import org.apache.logging.log4j.util.Strings;
22+
23+
import javax.servlet.http.HttpServletRequest;
24+
25+
/**
26+
*
27+
* Default resource isolation policy used in {@link FetchMetadataInterceptor} that
28+
* implements the {@link ResourceIsolationPolicy} interface. This default policy is based on
29+
* <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>.
30+
*
31+
* @see <a href="https://web.dev/fetch-metadata/">https://web.dev/fetch-metadata/</a>
32+
**/
33+
34+
public final class StrutsResourceIsolationPolicy implements ResourceIsolationPolicy {
35+
36+
@Override
37+
public boolean isRequestAllowed(HttpServletRequest request) {
38+
String site = request.getHeader(SEC_FETCH_SITE_HEADER);
39+
40+
// Allow requests from browsers which don't send Fetch Metadata
41+
if (Strings.isEmpty(site)){
42+
return true;
43+
}
44+
45+
// Allow same-site and browser-initiated requests
46+
if (SAME_ORIGIN.equals(site) || SAME_SITE.equals(site) || NONE.equals(site)) {
47+
return true;
48+
}
49+
50+
// Allow simple top-level navigations except <object> and <embed>
51+
return isAllowedTopLevelNavigation(request);
52+
}
53+
54+
private boolean isAllowedTopLevelNavigation(HttpServletRequest request) {
55+
String mode = request.getHeader(SEC_FETCH_MODE_HEADER);
56+
String dest = request.getHeader(SEC_FETCH_DEST_HEADER);
57+
58+
boolean isSimpleTopLevelNavigation = MODE_NAVIGATE.equals(mode) || "GET".equals(request.getMethod());
59+
boolean isNotObjectOrEmbedRequest = !DEST_EMBED.equals(dest) && !DEST_OBJECT.equals(dest);
60+
61+
return isSimpleTopLevelNavigation && isNotObjectOrEmbedRequest;
62+
}
63+
}

core/src/main/resources/struts-default.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@
273273
<interceptor name="annotationParameterFilter" class="com.opensymphony.xwork2.interceptor.annotations.AnnotationParameterFilterInterceptor" />
274274
<interceptor name="multiselect" class="org.apache.struts2.interceptor.MultiselectInterceptor" />
275275
<interceptor name="noop" class="org.apache.struts2.interceptor.NoOpInterceptor" />
276+
<interceptor name="fetchMetadata" class="org.apache.struts2.interceptor.FetchMetadataInterceptor" />
276277

277278
<!-- Empty stack - performs no operations -->
278279
<interceptor-stack name="emptyStack">
@@ -388,6 +389,7 @@
388389
<interceptor-ref name="actionMappingParams"/>
389390
<interceptor-ref name="params"/>
390391
<interceptor-ref name="conversionError"/>
392+
<interceptor-ref name="fetchMetadata"/>
391393
<interceptor-ref name="validation">
392394
<param name="excludeMethods">input,back,cancel,browse</param>
393395
</interceptor-ref>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.interceptor;
20+
21+
22+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_DEST_HEADER;
23+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_MODE_HEADER;
24+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.SEC_FETCH_SITE_HEADER;
25+
import static org.apache.struts2.interceptor.ResourceIsolationPolicy.VARY_HEADER;
26+
import static org.junit.Assert.assertNotEquals;
27+
28+
import com.opensymphony.xwork2.ActionContext;
29+
import com.opensymphony.xwork2.XWorkTestCase;
30+
import com.opensymphony.xwork2.mock.MockActionInvocation;
31+
import org.apache.struts2.ServletActionContext;
32+
import org.springframework.mock.web.MockHttpServletRequest;
33+
import org.springframework.mock.web.MockHttpServletResponse;
34+
35+
import java.util.Arrays;
36+
37+
public class FetchMetadataInterceptorTest extends XWorkTestCase {
38+
39+
private final FetchMetadataInterceptor interceptor = new FetchMetadataInterceptor();
40+
private final MockActionInvocation mai = new MockActionInvocation();
41+
private final MockHttpServletRequest request = new MockHttpServletRequest();
42+
private final MockHttpServletResponse response = new MockHttpServletResponse();
43+
private static final String VARY_HEADER_VALUE = String.format(
44+
"%s,%s,%s",
45+
SEC_FETCH_DEST_HEADER,
46+
SEC_FETCH_SITE_HEADER,
47+
SEC_FETCH_MODE_HEADER
48+
);
49+
50+
@Override
51+
protected void setUp() throws Exception {
52+
super.setUp();
53+
container.inject(interceptor);
54+
interceptor.setExemptedPaths("/foo,/bar");
55+
ServletActionContext.setRequest(request);
56+
ServletActionContext.setResponse(response);
57+
ActionContext context = ServletActionContext.getActionContext();
58+
mai.setInvocationContext(context);
59+
}
60+
61+
public void testNoSite() throws Exception {
62+
request.removeHeader("sec-fetch-site");
63+
64+
assertNotEquals("Expected interceptor to accept this request", "403",
65+
interceptor.intercept(mai));
66+
}
67+
68+
public void testValidSite() throws Exception {
69+
for (String header : Arrays.asList("same-origin", "same-site", "none")){
70+
request.addHeader("sec-fetch-site", header);
71+
72+
assertNotEquals("Expected interceptor to accept this request", "403",
73+
interceptor.intercept(mai));
74+
}
75+
76+
}
77+
78+
public void testValidTopLevelNavigation() throws Exception {
79+
request.addHeader("sec-fetch-mode", "navigate");
80+
request.addHeader("sec-fetch-dest", "script");
81+
request.setMethod("GET");
82+
83+
assertNotEquals("Expected interceptor to accept this request", "403",
84+
interceptor.intercept(mai));
85+
}
86+
87+
public void testInvalidTopLevelNavigation() throws Exception {
88+
for (String header : Arrays.asList("object", "embed")) {
89+
request.addHeader("sec-fetch-site", "foo");
90+
request.addHeader("sec-fetch-mode", "navigate");
91+
request.addHeader("sec-fetch-dest", header);
92+
request.setMethod("GET");
93+
94+
assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
95+
}
96+
}
97+
98+
public void testPathInExemptedPaths() throws Exception {
99+
request.addHeader("sec-fetch-site", "foo");
100+
request.setContextPath("/foo");
101+
102+
assertNotEquals("Expected interceptor to accept this request", "403",
103+
interceptor.intercept(mai));
104+
}
105+
106+
public void testPathNotInExemptedPaths() throws Exception {
107+
request.addHeader("sec-fetch-site", "foo");
108+
request.setContextPath("/foobar");
109+
110+
assertEquals("Expected interceptor to NOT accept this request", "403", interceptor.intercept(mai));
111+
}
112+
113+
public void testVaryHeaderAcceptedReq() throws Exception {
114+
request.addHeader("sec-fetch-site", "foo");
115+
request.setContextPath("/foo");
116+
117+
interceptor.intercept(mai);
118+
119+
assertTrue("Expected vary header to be included", response.containsHeader(VARY_HEADER));
120+
assertEquals("Expected different vary header value", response.getHeader(VARY_HEADER), VARY_HEADER_VALUE);
121+
}
122+
123+
public void testVaryHeaderRejectedReq() throws Exception {
124+
request.addHeader("sec-fetch-site", "foo");
125+
126+
interceptor.intercept(mai);
127+
128+
assertTrue("Expected vary header to be included", response.containsHeader(VARY_HEADER));
129+
assertEquals("Expected different vary header value", response.getHeader(VARY_HEADER), VARY_HEADER_VALUE);
130+
}
131+
}

0 commit comments

Comments
 (0)