001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.servlet.resource; 018 019import java.io.IOException; 020import java.io.UnsupportedEncodingException; 021import java.net.URLDecoder; 022import java.nio.charset.Charset; 023import java.nio.charset.StandardCharsets; 024import java.util.Arrays; 025import java.util.Collections; 026import java.util.HashMap; 027import java.util.List; 028import java.util.Map; 029import java.util.StringTokenizer; 030 031import javax.servlet.http.HttpServletRequest; 032 033import org.springframework.core.io.ClassPathResource; 034import org.springframework.core.io.Resource; 035import org.springframework.core.io.UrlResource; 036import org.springframework.lang.Nullable; 037import org.springframework.util.StringUtils; 038import org.springframework.web.context.support.ServletContextResource; 039import org.springframework.web.util.UriUtils; 040import org.springframework.web.util.UrlPathHelper; 041 042/** 043 * A simple {@code ResourceResolver} that tries to find a resource under the given 044 * locations matching to the request path. 045 * 046 * <p>This resolver does not delegate to the {@code ResourceResolverChain} and is 047 * expected to be configured at the end in a chain of resolvers. 048 * 049 * @author Jeremy Grelle 050 * @author Rossen Stoyanchev 051 * @author Sam Brannen 052 * @since 4.1 053 */ 054public class PathResourceResolver extends AbstractResourceResolver { 055 056 @Nullable 057 private Resource[] allowedLocations; 058 059 private final Map<Resource, Charset> locationCharsets = new HashMap<>(4); 060 061 @Nullable 062 private UrlPathHelper urlPathHelper; 063 064 065 /** 066 * By default when a Resource is found, the path of the resolved resource is 067 * compared to ensure it's under the input location where it was found. 068 * However sometimes that may not be the case, e.g. when 069 * {@link org.springframework.web.servlet.resource.CssLinkResourceTransformer} 070 * resolves public URLs of links it contains, the CSS file is the location 071 * and the resources being resolved are css files, images, fonts and others 072 * located in adjacent or parent directories. 073 * <p>This property allows configuring a complete list of locations under 074 * which resources must be so that if a resource is not under the location 075 * relative to which it was found, this list may be checked as well. 076 * <p>By default {@link ResourceHttpRequestHandler} initializes this property 077 * to match its list of locations. 078 * @param locations the list of allowed locations 079 * @since 4.1.2 080 * @see ResourceHttpRequestHandler#initAllowedLocations() 081 */ 082 public void setAllowedLocations(@Nullable Resource... locations) { 083 this.allowedLocations = locations; 084 } 085 086 @Nullable 087 public Resource[] getAllowedLocations() { 088 return this.allowedLocations; 089 } 090 091 /** 092 * Configure charsets associated with locations. If a static resource is found 093 * under a {@link org.springframework.core.io.UrlResource URL resource} 094 * location the charset is used to encode the relative path 095 * <p><strong>Note:</strong> the charset is used only if the 096 * {@link #setUrlPathHelper urlPathHelper} property is also configured and 097 * its {@code urlDecode} property is set to true. 098 * @since 4.3.13 099 */ 100 public void setLocationCharsets(Map<Resource, Charset> locationCharsets) { 101 this.locationCharsets.clear(); 102 this.locationCharsets.putAll(locationCharsets); 103 } 104 105 /** 106 * Return charsets associated with static resource locations. 107 * @since 4.3.13 108 */ 109 public Map<Resource, Charset> getLocationCharsets() { 110 return Collections.unmodifiableMap(this.locationCharsets); 111 } 112 113 /** 114 * Provide a reference to the {@link UrlPathHelper} used to map requests to 115 * static resources. This helps to derive information about the lookup path 116 * such as whether it is decoded or not. 117 * @since 4.3.13 118 */ 119 public void setUrlPathHelper(@Nullable UrlPathHelper urlPathHelper) { 120 this.urlPathHelper = urlPathHelper; 121 } 122 123 /** 124 * The configured {@link UrlPathHelper}. 125 * @since 4.3.13 126 */ 127 @Nullable 128 public UrlPathHelper getUrlPathHelper() { 129 return this.urlPathHelper; 130 } 131 132 133 @Override 134 protected Resource resolveResourceInternal(@Nullable HttpServletRequest request, String requestPath, 135 List<? extends Resource> locations, ResourceResolverChain chain) { 136 137 return getResource(requestPath, request, locations); 138 } 139 140 @Override 141 protected String resolveUrlPathInternal(String resourcePath, List<? extends Resource> locations, 142 ResourceResolverChain chain) { 143 144 return (StringUtils.hasText(resourcePath) && 145 getResource(resourcePath, null, locations) != null ? resourcePath : null); 146 } 147 148 @Nullable 149 private Resource getResource(String resourcePath, @Nullable HttpServletRequest request, 150 List<? extends Resource> locations) { 151 152 for (Resource location : locations) { 153 try { 154 String pathToUse = encodeIfNecessary(resourcePath, request, location); 155 Resource resource = getResource(pathToUse, location); 156 if (resource != null) { 157 return resource; 158 } 159 } 160 catch (IOException ex) { 161 if (logger.isDebugEnabled()) { 162 String error = "Skip location [" + location + "] due to error"; 163 if (logger.isTraceEnabled()) { 164 logger.trace(error, ex); 165 } 166 else { 167 logger.debug(error + ": " + ex.getMessage()); 168 } 169 } 170 } 171 } 172 return null; 173 } 174 175 /** 176 * Find the resource under the given location. 177 * <p>The default implementation checks if there is a readable 178 * {@code Resource} for the given path relative to the location. 179 * @param resourcePath the path to the resource 180 * @param location the location to check 181 * @return the resource, or {@code null} if none found 182 */ 183 @Nullable 184 protected Resource getResource(String resourcePath, Resource location) throws IOException { 185 Resource resource = location.createRelative(resourcePath); 186 if (resource.isReadable()) { 187 if (checkResource(resource, location)) { 188 return resource; 189 } 190 else if (logger.isWarnEnabled()) { 191 Resource[] allowedLocations = getAllowedLocations(); 192 logger.warn("Resource path \"" + resourcePath + "\" was successfully resolved " + 193 "but resource \"" + resource.getURL() + "\" is neither under the " + 194 "current location \"" + location.getURL() + "\" nor under any of the " + 195 "allowed locations " + (allowedLocations != null ? Arrays.asList(allowedLocations) : "[]")); 196 } 197 } 198 return null; 199 } 200 201 /** 202 * Perform additional checks on a resolved resource beyond checking whether the 203 * resources exists and is readable. The default implementation also verifies 204 * the resource is either under the location relative to which it was found or 205 * is under one of the {@link #setAllowedLocations allowed locations}. 206 * @param resource the resource to check 207 * @param location the location relative to which the resource was found 208 * @return "true" if resource is in a valid location, "false" otherwise. 209 * @since 4.1.2 210 */ 211 protected boolean checkResource(Resource resource, Resource location) throws IOException { 212 if (isResourceUnderLocation(resource, location)) { 213 return true; 214 } 215 Resource[] allowedLocations = getAllowedLocations(); 216 if (allowedLocations != null) { 217 for (Resource current : allowedLocations) { 218 if (isResourceUnderLocation(resource, current)) { 219 return true; 220 } 221 } 222 } 223 return false; 224 } 225 226 private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException { 227 if (resource.getClass() != location.getClass()) { 228 return false; 229 } 230 231 String resourcePath; 232 String locationPath; 233 234 if (resource instanceof UrlResource) { 235 resourcePath = resource.getURL().toExternalForm(); 236 locationPath = StringUtils.cleanPath(location.getURL().toString()); 237 } 238 else if (resource instanceof ClassPathResource) { 239 resourcePath = ((ClassPathResource) resource).getPath(); 240 locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); 241 } 242 else if (resource instanceof ServletContextResource) { 243 resourcePath = ((ServletContextResource) resource).getPath(); 244 locationPath = StringUtils.cleanPath(((ServletContextResource) location).getPath()); 245 } 246 else { 247 resourcePath = resource.getURL().getPath(); 248 locationPath = StringUtils.cleanPath(location.getURL().getPath()); 249 } 250 251 if (locationPath.equals(resourcePath)) { 252 return true; 253 } 254 locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); 255 return (resourcePath.startsWith(locationPath) && !isInvalidEncodedPath(resourcePath)); 256 } 257 258 private String encodeIfNecessary(String path, @Nullable HttpServletRequest request, Resource location) { 259 if (shouldEncodeRelativePath(location) && request != null) { 260 Charset charset = this.locationCharsets.getOrDefault(location, StandardCharsets.UTF_8); 261 StringBuilder sb = new StringBuilder(); 262 StringTokenizer tokenizer = new StringTokenizer(path, "/"); 263 while (tokenizer.hasMoreTokens()) { 264 String value = UriUtils.encode(tokenizer.nextToken(), charset); 265 sb.append(value); 266 sb.append("/"); 267 } 268 if (!path.endsWith("/")) { 269 sb.setLength(sb.length() - 1); 270 } 271 return sb.toString(); 272 } 273 else { 274 return path; 275 } 276 } 277 278 private boolean shouldEncodeRelativePath(Resource location) { 279 return (location instanceof UrlResource && this.urlPathHelper != null && this.urlPathHelper.isUrlDecode()); 280 } 281 282 private boolean isInvalidEncodedPath(String resourcePath) { 283 if (resourcePath.contains("%")) { 284 // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... 285 try { 286 String decodedPath = URLDecoder.decode(resourcePath, "UTF-8"); 287 if (decodedPath.contains("../") || decodedPath.contains("..\\")) { 288 logger.warn("Resolved resource path contains encoded \"../\" or \"..\\\": " + resourcePath); 289 return true; 290 } 291 } 292 catch (IllegalArgumentException ex) { 293 // May not be possible to decode... 294 } 295 catch (UnsupportedEncodingException ex) { 296 // Should never happen... 297 } 298 } 299 return false; 300 } 301 302}