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.reactive.resource; 018 019import java.io.IOException; 020import java.io.UnsupportedEncodingException; 021import java.net.URLDecoder; 022import java.nio.charset.StandardCharsets; 023import java.util.Arrays; 024import java.util.List; 025 026import reactor.core.publisher.Flux; 027import reactor.core.publisher.Mono; 028 029import org.springframework.core.io.ClassPathResource; 030import org.springframework.core.io.Resource; 031import org.springframework.core.io.UrlResource; 032import org.springframework.lang.Nullable; 033import org.springframework.util.StringUtils; 034import org.springframework.web.server.ServerWebExchange; 035import org.springframework.web.util.UriUtils; 036 037/** 038 * A simple {@code ResourceResolver} that tries to find a resource under the given 039 * locations matching to the request path. 040 * 041 * <p>This resolver does not delegate to the {@code ResourceResolverChain} and is 042 * expected to be configured at the end in a chain of resolvers. 043 * 044 * @author Rossen Stoyanchev 045 * @since 5.0 046 */ 047public class PathResourceResolver extends AbstractResourceResolver { 048 049 @Nullable 050 private Resource[] allowedLocations; 051 052 053 /** 054 * By default when a Resource is found, the path of the resolved resource is 055 * compared to ensure it's under the input location where it was found. 056 * However sometimes that may not be the case, e.g. when 057 * {@link CssLinkResourceTransformer} 058 * resolves public URLs of links it contains, the CSS file is the location 059 * and the resources being resolved are css files, images, fonts and others 060 * located in adjacent or parent directories. 061 * <p>This property allows configuring a complete list of locations under 062 * which resources must be so that if a resource is not under the location 063 * relative to which it was found, this list may be checked as well. 064 * <p>By default {@link ResourceWebHandler} initializes this property 065 * to match its list of locations. 066 * @param locations the list of allowed locations 067 */ 068 public void setAllowedLocations(@Nullable Resource... locations) { 069 this.allowedLocations = locations; 070 } 071 072 @Nullable 073 public Resource[] getAllowedLocations() { 074 return this.allowedLocations; 075 } 076 077 078 @Override 079 protected Mono<Resource> resolveResourceInternal(@Nullable ServerWebExchange exchange, 080 String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) { 081 082 return getResource(requestPath, locations); 083 } 084 085 @Override 086 protected Mono<String> resolveUrlPathInternal(String path, List<? extends Resource> locations, 087 ResourceResolverChain chain) { 088 089 if (StringUtils.hasText(path)) { 090 return getResource(path, locations).map(resource -> path); 091 } 092 else { 093 return Mono.empty(); 094 } 095 } 096 097 private Mono<Resource> getResource(String resourcePath, List<? extends Resource> locations) { 098 return Flux.fromIterable(locations) 099 .concatMap(location -> getResource(resourcePath, location)) 100 .next(); 101 } 102 103 /** 104 * Find the resource under the given location. 105 * <p>The default implementation checks if there is a readable 106 * {@code Resource} for the given path relative to the location. 107 * @param resourcePath the path to the resource 108 * @param location the location to check 109 * @return the resource, or empty {@link Mono} if none found 110 */ 111 protected Mono<Resource> getResource(String resourcePath, Resource location) { 112 try { 113 if (location instanceof ClassPathResource) { 114 resourcePath = UriUtils.decode(resourcePath, StandardCharsets.UTF_8); 115 } 116 Resource resource = location.createRelative(resourcePath); 117 if (resource.isReadable()) { 118 if (checkResource(resource, location)) { 119 return Mono.just(resource); 120 } 121 else if (logger.isWarnEnabled()) { 122 Resource[] allowedLocations = getAllowedLocations(); 123 logger.warn("Resource path \"" + resourcePath + "\" was successfully resolved " + 124 "but resource \"" + resource.getURL() + "\" is neither under the " + 125 "current location \"" + location.getURL() + "\" nor under any of the " + 126 "allowed locations " + (allowedLocations != null ? Arrays.asList(allowedLocations) : "[]")); 127 } 128 } 129 return Mono.empty(); 130 } 131 catch (IOException ex) { 132 if (logger.isDebugEnabled()) { 133 String error = "Skip location [" + location + "] due to error"; 134 if (logger.isTraceEnabled()) { 135 logger.trace(error, ex); 136 } 137 else { 138 logger.debug(error + ": " + ex.getMessage()); 139 } 140 } 141 return Mono.error(ex); 142 } 143 } 144 145 /** 146 * Perform additional checks on a resolved resource beyond checking whether the 147 * resources exists and is readable. The default implementation also verifies 148 * the resource is either under the location relative to which it was found or 149 * is under one of the {@link #setAllowedLocations allowed locations}. 150 * @param resource the resource to check 151 * @param location the location relative to which the resource was found 152 * @return "true" if resource is in a valid location, "false" otherwise. 153 */ 154 protected boolean checkResource(Resource resource, Resource location) throws IOException { 155 if (isResourceUnderLocation(resource, location)) { 156 return true; 157 } 158 if (getAllowedLocations() != null) { 159 for (Resource current : getAllowedLocations()) { 160 if (isResourceUnderLocation(resource, current)) { 161 return true; 162 } 163 } 164 } 165 return false; 166 } 167 168 private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException { 169 if (resource.getClass() != location.getClass()) { 170 return false; 171 } 172 173 String resourcePath; 174 String locationPath; 175 176 if (resource instanceof UrlResource) { 177 resourcePath = resource.getURL().toExternalForm(); 178 locationPath = StringUtils.cleanPath(location.getURL().toString()); 179 } 180 else if (resource instanceof ClassPathResource) { 181 resourcePath = ((ClassPathResource) resource).getPath(); 182 locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); 183 } 184 else { 185 resourcePath = resource.getURL().getPath(); 186 locationPath = StringUtils.cleanPath(location.getURL().getPath()); 187 } 188 189 if (locationPath.equals(resourcePath)) { 190 return true; 191 } 192 locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); 193 return (resourcePath.startsWith(locationPath) && !isInvalidEncodedPath(resourcePath)); 194 } 195 196 private boolean isInvalidEncodedPath(String resourcePath) { 197 if (resourcePath.contains("%")) { 198 // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... 199 try { 200 String decodedPath = URLDecoder.decode(resourcePath, "UTF-8"); 201 if (decodedPath.contains("../") || decodedPath.contains("..\\")) { 202 logger.warn("Resolved resource path contains encoded \"../\" or \"..\\\": " + resourcePath); 203 return true; 204 } 205 } 206 catch (IllegalArgumentException ex) { 207 // May not be possible to decode... 208 } 209 catch (UnsupportedEncodingException ex) { 210 // Should never happen... 211 } 212 } 213 return false; 214 } 215 216}