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