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}