Skip to content

Commit 9cef8e3

Browse files
rstoyanchevsnicoll
authored andcommitted
Apply extra checks to static resource handling
- remove leading '/' and control chars - improve url and relative path checks - account for URL encoding - add isResourceUnderLocation final verification Issue: SPR-12354
1 parent a831ed5 commit 9cef8e3

File tree

7 files changed

+404
-52
lines changed

7 files changed

+404
-52
lines changed

spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@
1717
package org.springframework.web.servlet.resource;
1818

1919
import java.io.IOException;
20+
import java.net.URLDecoder;
2021
import java.util.List;
22+
2123
import javax.servlet.http.HttpServletRequest;
2224

25+
import org.springframework.core.io.ClassPathResource;
2326
import org.springframework.core.io.Resource;
27+
import org.springframework.core.io.UrlResource;
2428

2529
/**
2630
* A simple {@code ResourceResolver} that tries to find a resource under the given
@@ -36,6 +40,35 @@
3640
*/
3741
public class PathResourceResolver extends AbstractResourceResolver {
3842

43+
private Resource[] allowedLocations;
44+
45+
46+
/**
47+
* By default when a Resource is found, the path of the resolved resource is
48+
* compared to ensure it's under the input location where it was found.
49+
* However sometimes that may not be the case, e.g. when
50+
* {@link org.springframework.web.servlet.resource.CssLinkResourceTransformer}
51+
* resolves public URLs of links it contains, the CSS file is the location
52+
* and the resources being resolved are css files, images, fonts and others
53+
* located in adjacent or parent directories.
54+
* <p>This property allows configuring a complete list of locations under
55+
* which resources must be so that if a resource is not under the location
56+
* relative to which it was found, this list may be checked as well.
57+
* <p>By default {@link ResourceHttpRequestHandler} initializes this property
58+
* to match its list of locations.
59+
* @param locations the list of allowed locations
60+
* @since 4.1.2
61+
* @see ResourceHttpRequestHandler#initAllowedLocations()
62+
*/
63+
public void setAllowedLocations(Resource... locations) {
64+
this.allowedLocations = locations;
65+
}
66+
67+
public Resource[] getAllowedLocations() {
68+
return this.allowedLocations;
69+
}
70+
71+
3972
@Override
4073
protected Resource resolveResourceInternal(HttpServletRequest request, String requestPath,
4174
List<? extends Resource> locations, ResourceResolverChain chain) {
@@ -84,7 +117,79 @@ else if (logger.isTraceEnabled()) {
84117
*/
85118
protected Resource getResource(String resourcePath, Resource location) throws IOException {
86119
Resource resource = location.createRelative(resourcePath);
87-
return (resource.exists() && resource.isReadable() ? resource : null);
120+
if (resource.exists() && resource.isReadable()) {
121+
if (checkResource(resource, location)) {
122+
return resource;
123+
}
124+
else {
125+
if (logger.isTraceEnabled()) {
126+
logger.trace("resourcePath=\"" + resourcePath + "\" was successfully resolved " +
127+
"but resource=\"" + resource.getURL() + "\" is neither under the " +
128+
"current location=\"" + location.getURL() + "\" nor under any of the " +
129+
"allowed locations=" + getAllowedLocations());
130+
}
131+
}
132+
}
133+
return null;
134+
}
135+
136+
/**
137+
* Perform additional checks on a resolved resource beyond checking whether
138+
* the resources exists and is readable. The default implementation also
139+
* verifies the resource is either under the location relative to which it
140+
* was found or is under one of the {@link #setAllowedLocations allowed
141+
* locations}.
142+
* @param resource the resource to check
143+
* @param location the location relative to which the resource was found
144+
* @return "true" if resource is in a valid location, "false" otherwise.
145+
* @since 4.1.2
146+
*/
147+
protected boolean checkResource(Resource resource, Resource location) throws IOException {
148+
if (isResourceUnderLocation(resource, location)) {
149+
return true;
150+
}
151+
if (getAllowedLocations() != null) {
152+
for (Resource current : getAllowedLocations()) {
153+
if (isResourceUnderLocation(resource, current)) {
154+
return true;
155+
}
156+
}
157+
}
158+
return false;
159+
}
160+
161+
private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException {
162+
if (!resource.getClass().equals(location.getClass())) {
163+
return false;
164+
}
165+
String resourcePath;
166+
String locationPath;
167+
if (resource instanceof ClassPathResource) {
168+
resourcePath = ((ClassPathResource) resource).getPath();
169+
locationPath = ((ClassPathResource) location).getPath();
170+
}
171+
else if (resource instanceof UrlResource) {
172+
resourcePath = resource.getURL().toExternalForm();
173+
locationPath = location.getURL().toExternalForm();
174+
}
175+
else {
176+
resourcePath = resource.getURL().getPath();
177+
locationPath = location.getURL().getPath();
178+
}
179+
locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/");
180+
if (!resourcePath.startsWith(locationPath)) {
181+
return false;
182+
}
183+
if (resourcePath.contains("%")) {
184+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
185+
if (URLDecoder.decode(resourcePath, "UTF-8").contains("../")) {
186+
if (logger.isTraceEnabled()) {
187+
logger.trace("Resolved resource path contains \"../\" after decoding: " + resourcePath);
188+
}
189+
return false;
190+
}
191+
}
192+
return true;
88193
}
89194

90195
}

spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818

1919
import java.io.IOException;
2020
import java.io.InputStream;
21+
import java.net.URLDecoder;
2122
import java.util.ArrayList;
2223
import java.util.List;
24+
2325
import javax.activation.FileTypeMap;
2426
import javax.activation.MimetypesFileTypeMap;
2527
import javax.servlet.ServletException;
@@ -28,14 +30,15 @@
2830

2931
import org.apache.commons.logging.Log;
3032
import org.apache.commons.logging.LogFactory;
31-
3233
import org.springframework.beans.factory.InitializingBean;
3334
import org.springframework.core.io.ClassPathResource;
3435
import org.springframework.core.io.Resource;
3536
import org.springframework.http.MediaType;
3637
import org.springframework.util.Assert;
3738
import org.springframework.util.ClassUtils;
3839
import org.springframework.util.CollectionUtils;
40+
import org.springframework.util.ObjectUtils;
41+
import org.springframework.util.ResourceUtils;
3942
import org.springframework.util.StreamUtils;
4043
import org.springframework.util.StringUtils;
4144
import org.springframework.web.HttpRequestHandler;
@@ -158,6 +161,29 @@ public void afterPropertiesSet() throws Exception {
158161
logger.warn("Locations list is empty. No resources will be served unless a " +
159162
"custom ResourceResolver is configured as an alternative to PathResourceResolver.");
160163
}
164+
initAllowedLocations();
165+
}
166+
167+
/**
168+
* Look for a {@link org.springframework.web.servlet.resource.PathResourceResolver}
169+
* among the {@link #getResourceResolvers() resource resolvers} and configure
170+
* its {@code "allowedLocations"} to match the value of the
171+
* {@link #setLocations(java.util.List) locations} property unless the "allowed
172+
* locations" of the {@code PathResourceResolver} is non-empty.
173+
*/
174+
protected void initAllowedLocations() {
175+
if (CollectionUtils.isEmpty(this.locations)) {
176+
return;
177+
}
178+
for (int i = getResourceResolvers().size()-1; i >= 0; i--) {
179+
if (getResourceResolvers().get(i) instanceof PathResourceResolver) {
180+
PathResourceResolver pathResolver = (PathResourceResolver) getResourceResolvers().get(i);
181+
if (ObjectUtils.isEmpty(pathResolver.getAllowedLocations())) {
182+
pathResolver.setAllowedLocations(getLocations().toArray(new Resource[getLocations().size()]));
183+
}
184+
break;
185+
}
186+
}
161187
}
162188

163189
/**
@@ -214,18 +240,33 @@ public void handleRequest(HttpServletRequest request, HttpServletResponse respon
214240
writeContent(response, resource);
215241
}
216242

217-
protected Resource getResource(HttpServletRequest request) throws IOException{
243+
protected Resource getResource(HttpServletRequest request) throws IOException {
218244
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
219245
if (path == null) {
220246
throw new IllegalStateException("Required request attribute '" +
221247
HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE + "' is not set");
222248
}
249+
path = processPath(path);
223250
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
224251
if (logger.isTraceEnabled()) {
225252
logger.trace("Ignoring invalid resource path [" + path + "]");
226253
}
227254
return null;
228255
}
256+
if (path.contains("%")) {
257+
try {
258+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
259+
if (isInvalidPath(URLDecoder.decode(path, "UTF-8"))) {
260+
if (logger.isTraceEnabled()) {
261+
logger.trace("Ignoring invalid resource path with escape sequences [" + path + "].");
262+
}
263+
return null;
264+
}
265+
}
266+
catch (IllegalArgumentException ex) {
267+
// ignore
268+
}
269+
}
229270
ResourceResolverChain resolveChain = new DefaultResourceResolverChain(getResourceResolvers());
230271
Resource resource = resolveChain.resolveResource(request, path, getLocations());
231272
if (resource == null || getResourceTransformers().isEmpty()) {
@@ -237,14 +278,76 @@ protected Resource getResource(HttpServletRequest request) throws IOException{
237278
}
238279

239280
/**
240-
* Validates the given path: returns {@code true} if the given path is not a valid resource path.
241-
* <p>The default implementation rejects paths containing "WEB-INF" or "META-INF" as well as paths
242-
* with relative paths ("../") that result in access of a parent directory.
281+
* Process the given resource path to be used.
282+
* <p>The default implementation replaces any combination of leading '/' and
283+
* control characters (00-1F and 7F) with a single "/" or "". For example
284+
* {@code " // /// //// foo/bar"} becomes {@code "/foo/bar"}.
285+
* @since 3.2.12
286+
*/
287+
protected String processPath(String path) {
288+
boolean slash = false;
289+
for (int i = 0; i < path.length(); i++) {
290+
if (path.charAt(i) == '/') {
291+
slash = true;
292+
}
293+
else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
294+
if (i == 0 || (i == 1 && slash)) {
295+
return path;
296+
}
297+
path = slash ? "/" + path.substring(i) : path.substring(i);
298+
if (logger.isTraceEnabled()) {
299+
logger.trace("Path after trimming leading '/' and control characters: " + path);
300+
}
301+
return path;
302+
}
303+
}
304+
return (slash ? "/" : "");
305+
}
306+
307+
/**
308+
* Identifies invalid resource paths. By default rejects:
309+
* <ul>
310+
* <li>Paths that contain "WEB-INF" or "META-INF"
311+
* <li>Paths that contain "../" after a call to
312+
* {@link org.springframework.util.StringUtils#cleanPath}.
313+
* <li>Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl
314+
* valid URL} or would represent one after the leading slash is removed.
315+
* </ul>
316+
* <p><strong>Note:</strong> this method assumes that leading, duplicate '/'
317+
* or control characters (e.g. white space) have been trimmed so that the
318+
* path starts predictably with a single '/' or does not have one.
243319
* @param path the path to validate
244-
* @return {@code true} if the path has been recognized as invalid, {@code false} otherwise
320+
* @return {@code true} if the path is invalid, {@code false} otherwise
245321
*/
246322
protected boolean isInvalidPath(String path) {
247-
return (path.contains("WEB-INF") || path.contains("META-INF") || StringUtils.cleanPath(path).startsWith(".."));
323+
if (logger.isTraceEnabled()) {
324+
logger.trace("Applying \"invalid path\" checks to path: " + path);
325+
}
326+
if (path.contains("WEB-INF") || path.contains("META-INF")) {
327+
if (logger.isTraceEnabled()) {
328+
logger.trace("Path contains \"WEB-INF\" or \"META-INF\".");
329+
}
330+
return true;
331+
}
332+
if (path.contains(":/")) {
333+
String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path);
334+
if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) {
335+
if (logger.isTraceEnabled()) {
336+
logger.trace("Path represents URL or has \"url:\" prefix.");
337+
}
338+
return true;
339+
}
340+
}
341+
if (path.contains("../")) {
342+
path = StringUtils.cleanPath(path);
343+
if (path.contains("../")) {
344+
if (logger.isTraceEnabled()) {
345+
logger.trace("Path contains \"../\" after call to StringUtils#cleanPath.");
346+
}
347+
return true;
348+
}
349+
}
350+
return false;
248351
}
249352

250353
/**

spring-webmvc/src/test/java/org/springframework/web/servlet/resource/AppCacheManifestTransformerTests.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.web.servlet.resource;
1818

1919
import java.util.ArrayList;
20+
import java.util.Arrays;
2021
import java.util.Collections;
2122
import java.util.List;
2223
import javax.servlet.http.HttpServletRequest;
@@ -75,13 +76,13 @@ public void syntaxErrorInManifest() throws Exception {
7576
@Test
7677
public void transformManifest() throws Exception {
7778

78-
VersionResourceResolver versionResourceResolver = new VersionResourceResolver();
79-
versionResourceResolver
80-
.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy()));
79+
VersionResourceResolver versionResolver = new VersionResourceResolver();
80+
versionResolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy()));
8181

82-
List<ResourceResolver> resolvers = new ArrayList<ResourceResolver>();
83-
resolvers.add(versionResourceResolver);
84-
resolvers.add(new PathResourceResolver());
82+
PathResourceResolver pathResolver = new PathResourceResolver();
83+
pathResolver.setAllowedLocations(new ClassPathResource("test/", getClass()));
84+
85+
List<ResourceResolver> resolvers = Arrays.asList(versionResolver, pathResolver);
8586
ResourceResolverChain resolverChain = new DefaultResourceResolverChain(resolvers);
8687

8788
List<ResourceTransformer> transformers = new ArrayList<>();

spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ public class CssLinkResourceTransformerTests {
4747

4848
@Before
4949
public void setUp() {
50-
VersionResourceResolver resolver = new VersionResourceResolver();
51-
resolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy()));
50+
VersionResourceResolver versionResolver = new VersionResourceResolver();
51+
versionResolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy()));
5252

53-
List<ResourceResolver> resolvers = Arrays.asList(resolver, new PathResourceResolver());
53+
PathResourceResolver pathResolver = new PathResourceResolver();
54+
pathResolver.setAllowedLocations(new ClassPathResource("test/", getClass()));
55+
56+
List<ResourceResolver> resolvers = Arrays.asList(versionResolver, pathResolver);
5457
List<ResourceTransformer> transformers = Arrays.asList(new CssLinkResourceTransformer());
5558

5659
ResourceResolverChain resolverChain = new DefaultResourceResolverChain(resolvers);

0 commit comments

Comments
 (0)