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}