diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java index aa314b62356..20c841538ef 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java @@ -18,20 +18,14 @@ package org.springframework.web.reactive.function.server; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.function.Function; import reactor.core.publisher.Mono; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; import org.springframework.http.server.PathContainer; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.util.UriUtils; +import org.springframework.web.reactive.resource.ResourceHandlerUtils; import org.springframework.web.util.pattern.PathPattern; import org.springframework.web.util.pattern.PathPatternParser; @@ -64,21 +58,14 @@ class PathResourceLookupFunction implements FunctionThe default implementation replaces: - * - */ - protected String processPath(String path) { - path = StringUtils.replace(path, "\\", "/"); - path = cleanDuplicateSlashes(path); - return cleanLeadingSlash(path); - } - - private String cleanDuplicateSlashes(String path) { - StringBuilder sb = null; - char prev = 0; - for (int i = 0; i < path.length(); i++) { - char curr = path.charAt(i); - try { - if (curr == '/' && prev == '/') { - if (sb == null) { - sb = new StringBuilder(path.substring(0, i)); - } - continue; - } - if (sb != null) { - sb.append(path.charAt(i)); - } - } - finally { - prev = curr; - } - } - return (sb != null ? sb.toString() : path); - } - - private String cleanLeadingSlash(String path) { - boolean slash = false; - for (int i = 0; i < path.length(); i++) { - if (path.charAt(i) == '/') { - slash = true; - } - else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { - if (i == 0 || (i == 1 && slash)) { - return path; - } - return (slash ? "/" + path.substring(i) : path.substring(i)); - } - } - return (slash ? "/" : ""); - } - - private boolean isInvalidPath(String path) { - if (path.contains("WEB-INF") || path.contains("META-INF")) { - return true; - } - if (path.contains(":/")) { - String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); - if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { - return true; - } - } - if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { - return true; - } - return false; - } - - /** - * Check whether the given path contains invalid escape sequences. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise - */ - private boolean isInvalidEncodedInputPath(String path) { - if (path.contains("%")) { - try { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars - String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); - if (isInvalidPath(decodedPath)) { - return true; - } - decodedPath = processPath(decodedPath); - if (isInvalidPath(decodedPath)) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - - private boolean isResourceUnderLocation(Resource resource) throws IOException { - if (resource.getClass() != this.location.getClass()) { - return false; - } - - String resourcePath; - String locationPath; - - if (resource instanceof UrlResource) { - resourcePath = resource.getURL().toExternalForm(); - locationPath = StringUtils.cleanPath(this.location.getURL().toString()); - } - else if (resource instanceof ClassPathResource classPathResource) { - resourcePath = classPathResource.getPath(); - locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath()); - } - else { - resourcePath = resource.getURL().getPath(); - locationPath = StringUtils.cleanPath(this.location.getURL().getPath()); - } - - if (locationPath.equals(resourcePath)) { - return true; - } - locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); - return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath)); - } - - private boolean isInvalidEncodedResourcePath(String resourcePath) { - if (resourcePath.contains("%")) { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... - try { - String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); - if (decodedPath.contains("../") || decodedPath.contains("..\\")) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - @Override public String toString() { return this.pattern + " -> " + this.location; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java index 87044b1fe14..c9b7c56fcf1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,22 +17,17 @@ package org.springframework.web.reactive.resource; import java.io.IOException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; import org.springframework.core.log.LogFormatUtils; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.util.UriUtils; /** * A simple {@code ResourceResolver} that tries to find a resource under the given @@ -111,10 +106,7 @@ public class PathResourceResolver extends AbstractResourceResolver { */ protected Mono getResource(String resourcePath, Resource location) { try { - if (!(location instanceof UrlResource)) { - resourcePath = UriUtils.decode(resourcePath, StandardCharsets.UTF_8); - } - Resource resource = location.createRelative(resourcePath); + Resource resource = ResourceHandlerUtils.createRelativeResource(location, resourcePath); if (resource.isReadable()) { if (checkResource(resource, location)) { return Mono.just(resource); @@ -154,61 +146,15 @@ public class PathResourceResolver extends AbstractResourceResolver { * @return "true" if resource is in a valid location, "false" otherwise */ protected boolean checkResource(Resource resource, Resource location) throws IOException { - if (isResourceUnderLocation(resource, location)) { + if (ResourceHandlerUtils.isResourceUnderLocation(location, resource)) { return true; } if (getAllowedLocations() != null) { for (Resource current : getAllowedLocations()) { - if (isResourceUnderLocation(resource, current)) { - return true; - } - } - } - return false; - } - - private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException { - if (resource.getClass() != location.getClass()) { - return false; - } - - String resourcePath; - String locationPath; - - if (resource instanceof UrlResource) { - resourcePath = resource.getURL().toExternalForm(); - locationPath = StringUtils.cleanPath(location.getURL().toString()); - } - else if (resource instanceof ClassPathResource classPathResource) { - resourcePath = classPathResource.getPath(); - locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); - } - else { - resourcePath = resource.getURL().getPath(); - locationPath = StringUtils.cleanPath(location.getURL().getPath()); - } - - if (locationPath.equals(resourcePath)) { - return true; - } - locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); - return (resourcePath.startsWith(locationPath) && !isInvalidEncodedPath(resourcePath)); - } - - private boolean isInvalidEncodedPath(String resourcePath) { - if (resourcePath.contains("%")) { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... - try { - String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); - if (decodedPath.contains("../") || decodedPath.contains("..\\")) { - logger.warn(LogFormatUtils.formatValue( - "Resolved resource path contains encoded \"../\" or \"..\\\": " + resourcePath, -1, true)); + if (ResourceHandlerUtils.isResourceUnderLocation(current, resource)) { return true; } } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } } return false; } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceHandlerUtils.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceHandlerUtils.java new file mode 100644 index 00000000000..0c8e148f43a --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceHandlerUtils.java @@ -0,0 +1,231 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.resource; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.core.log.LogFormatUtils; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriUtils; + +/** + * Resource handling utility methods to share common logic between + * {@link ResourceWebHandler} and {@link org.springframework.web.reactive.function.server}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public abstract class ResourceHandlerUtils { + + private static final Log logger = LogFactory.getLog(ResourceHandlerUtils.class); + + + /** + * Normalize the given resource path replacing the following: + * + */ + public static String normalizeInputPath(String path) { + path = StringUtils.replace(path, "\\", "/"); + path = cleanDuplicateSlashes(path); + return cleanLeadingSlash(path); + } + + private static String cleanDuplicateSlashes(String path) { + StringBuilder sb = null; + char prev = 0; + for (int i = 0; i < path.length(); i++) { + char curr = path.charAt(i); + try { + if (curr == '/' && prev == '/') { + if (sb == null) { + sb = new StringBuilder(path.substring(0, i)); + } + continue; + } + if (sb != null) { + sb.append(path.charAt(i)); + } + } + finally { + prev = curr; + } + } + return (sb != null ? sb.toString() : path); + } + + private static String cleanLeadingSlash(String path) { + boolean slash = false; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '/') { + slash = true; + } + else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { + if (i == 0 || (i == 1 && slash)) { + return path; + } + return (slash ? "/" + path.substring(i) : path.substring(i)); + } + } + return (slash ? "/" : ""); + } + + /** + * Whether the given input path is invalid as determined by + * {@link #isInvalidPath(String)}. The path is also decoded and the same + * checks are performed again. + */ + public static boolean shouldIgnoreInputPath(String path) { + return (!StringUtils.hasText(path) || isInvalidPath(path) || isInvalidEncodedPath(path)); + } + + /** + * Checks for invalid resource input paths rejecting the following: + * + *

Note: this method assumes that leading, duplicate '/' + * or control characters (e.g. white space) have been trimmed so that the + * path starts predictably with a single '/' or does not have one. + * @param path the path to validate + * @return {@code true} if the path is invalid, {@code false} otherwise + */ + public static boolean isInvalidPath(String path) { + if (path.contains("WEB-INF") || path.contains("META-INF")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path with \"WEB-INF\" or \"META-INF\": [" + path + "]", -1, true)); + } + return true; + } + if (path.contains(":/")) { + String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); + if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path represents URL or has \"url:\" prefix: [" + path + "]", -1, true)); + } + return true; + } + } + if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]", -1, true)); + } + return true; + } + return false; + } + + private static boolean isInvalidEncodedPath(String path) { + if (path.contains("%")) { + try { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars + String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); + if (isInvalidPath(decodedPath)) { + return true; + } + decodedPath = normalizeInputPath(decodedPath); + if (isInvalidPath(decodedPath)) { + return true; + } + } + catch (IllegalArgumentException ex) { + // May not be possible to decode... + } + } + return false; + } + + /** + * Create a resource relative to the given {@link Resource}, also decoding + * the resource path for a {@link UrlResource}. + */ + public static Resource createRelativeResource(Resource location, String resourcePath) throws IOException { + if (!(location instanceof UrlResource)) { + resourcePath = UriUtils.decode(resourcePath, StandardCharsets.UTF_8); + } + return location.createRelative(resourcePath); + } + + /** + * Check whether the resource is under the given location. + */ + public static boolean isResourceUnderLocation(Resource location, Resource resource) throws IOException { + if (resource.getClass() != location.getClass()) { + return false; + } + + String resourcePath; + String locationPath; + + if (resource instanceof UrlResource) { + resourcePath = resource.getURL().toExternalForm(); + locationPath = StringUtils.cleanPath(location.getURL().toString()); + } + else if (resource instanceof ClassPathResource classPathResource) { + resourcePath = classPathResource.getPath(); + locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); + } + else { + resourcePath = resource.getURL().getPath(); + locationPath = StringUtils.cleanPath(location.getURL().getPath()); + } + + if (locationPath.equals(resourcePath)) { + return true; + } + locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); + return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath)); + } + + private static boolean isInvalidEncodedResourcePath(String resourcePath) { + if (resourcePath.contains("%")) { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... + try { + String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); + if (decodedPath.contains("../") || decodedPath.contains("..\\")) { + return true; + } + } + catch (IllegalArgumentException ex) { + // May not be possible to decode... + } + } + return false; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java index 3f4721a5b02..a2f611cd4f5 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,6 @@ package org.springframework.web.reactive.resource; import java.io.IOException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -38,7 +36,6 @@ import org.springframework.core.ResolvableType; import org.springframework.core.codec.Hints; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import org.springframework.core.log.LogFormatUtils; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -50,7 +47,6 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.server.MethodNotAllowedException; @@ -488,10 +484,7 @@ public class ResourceWebHandler implements WebHandler, InitializingBean { protected Mono getResource(ServerWebExchange exchange) { String rawPath = getResourcePath(exchange); String path = processPath(rawPath); - if (!StringUtils.hasText(path) || isInvalidPath(path)) { - return Mono.empty(); - } - if (isInvalidEncodedPath(path)) { + if (ResourceHandlerUtils.shouldIgnoreInputPath(path) || isInvalidPath(path)) { return Mono.empty(); } @@ -513,125 +506,18 @@ public class ResourceWebHandler implements WebHandler, InitializingBean { /** * Process the given resource path. - *

The default implementation replaces: - *

+ *

By default, this method delegates to {@link ResourceHandlerUtils#normalizeInputPath}. */ protected String processPath(String path) { - path = StringUtils.replace(path, "\\", "/"); - path = cleanDuplicateSlashes(path); - return cleanLeadingSlash(path); - } - - private String cleanDuplicateSlashes(String path) { - StringBuilder sb = null; - char prev = 0; - for (int i = 0; i < path.length(); i++) { - char curr = path.charAt(i); - try { - if (curr == '/' && prev == '/') { - if (sb == null) { - sb = new StringBuilder(path.substring(0, i)); - } - continue; - } - if (sb != null) { - sb.append(path.charAt(i)); - } - } - finally { - prev = curr; - } - } - return (sb != null ? sb.toString() : path); - } - - private String cleanLeadingSlash(String path) { - boolean slash = false; - for (int i = 0; i < path.length(); i++) { - if (path.charAt(i) == '/') { - slash = true; - } - else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { - if (i == 0 || (i == 1 && slash)) { - return path; - } - return (slash ? "/" + path.substring(i) : path.substring(i)); - } - } - return (slash ? "/" : ""); + return ResourceHandlerUtils.normalizeInputPath(path); } /** - * Check whether the given path contains invalid escape sequences. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise - */ - private boolean isInvalidEncodedPath(String path) { - if (path.contains("%")) { - try { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars - String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); - if (isInvalidPath(decodedPath)) { - return true; - } - decodedPath = processPath(decodedPath); - if (isInvalidPath(decodedPath)) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - - /** - * Identifies invalid resource paths. By default rejects: - *

- *

Note: this method assumes that leading, duplicate '/' - * or control characters (e.g. white space) have been trimmed so that the - * path starts predictably with a single '/' or does not have one. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise + * Invoked after {@link ResourceHandlerUtils#isInvalidPath(String)} + * to allow subclasses to perform further validation. + *

By default, this method does not perform any validations. */ protected boolean isInvalidPath(String path) { - if (path.contains("WEB-INF") || path.contains("META-INF")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path with \"WEB-INF\" or \"META-INF\": [" + path + "]", -1, true)); - } - return true; - } - if (path.contains(":/")) { - String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); - if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path represents URL or has \"url:\" prefix: [" + path + "]", -1, true)); - } - return true; - } - } - if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]", -1, true)); - } - return true; - } return false; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java index e9c700f10f7..80dd6212750 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,19 +18,15 @@ package org.springframework.web.servlet.function; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Optional; import java.util.function.Function; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.server.PathContainer; import org.springframework.util.Assert; -import org.springframework.util.ResourceUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.context.support.ServletContextResource; +import org.springframework.web.servlet.resource.ResourceHandlerUtils; import org.springframework.web.util.UriUtils; import org.springframework.web.util.pattern.PathPattern; import org.springframework.web.util.pattern.PathPatternParser; @@ -66,10 +62,7 @@ class PathResourceLookupFunction implements FunctionThe default implementation replaces: - *

+ *

By default, this method delegates to {@link ResourceHandlerUtils#normalizeInputPath}. */ protected String processPath(String path) { - path = StringUtils.replace(path, "\\", "/"); - path = cleanDuplicateSlashes(path); - return cleanLeadingSlash(path); - } - - private String cleanDuplicateSlashes(String path) { - StringBuilder sb = null; - char prev = 0; - for (int i = 0; i < path.length(); i++) { - char curr = path.charAt(i); - try { - if ((curr == '/') && (prev == '/')) { - if (sb == null) { - sb = new StringBuilder(path.substring(0, i)); - } - continue; - } - if (sb != null) { - sb.append(path.charAt(i)); - } - } - finally { - prev = curr; - } - } - return sb != null ? sb.toString() : path; - } - - private String cleanLeadingSlash(String path) { - boolean slash = false; - for (int i = 0; i < path.length(); i++) { - if (path.charAt(i) == '/') { - slash = true; - } - else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { - if (i == 0 || (i == 1 && slash)) { - return path; - } - return (slash ? "/" + path.substring(i) : path.substring(i)); - } - } - return (slash ? "/" : ""); - } - - private boolean isInvalidPath(String path) { - if (path.contains("WEB-INF") || path.contains("META-INF")) { - return true; - } - if (path.contains(":/")) { - String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); - if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { - return true; - } - } - return path.contains("..") && StringUtils.cleanPath(path).contains("../"); - } - - private boolean isInvalidEncodedInputPath(String path) { - if (path.contains("%")) { - try { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars - String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); - if (isInvalidPath(decodedPath)) { - return true; - } - decodedPath = processPath(decodedPath); - if (isInvalidPath(decodedPath)) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - - private boolean isResourceUnderLocation(Resource resource) throws IOException { - if (resource.getClass() != this.location.getClass()) { - return false; - } - - String resourcePath; - String locationPath; - - if (resource instanceof UrlResource) { - resourcePath = resource.getURL().toExternalForm(); - locationPath = StringUtils.cleanPath(this.location.getURL().toString()); - } - else if (resource instanceof ClassPathResource classPathResource) { - resourcePath = classPathResource.getPath(); - locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath()); - } - else if (resource instanceof ServletContextResource servletContextResource) { - resourcePath = servletContextResource.getPath(); - locationPath = StringUtils.cleanPath(((ServletContextResource) this.location).getPath()); - } - else { - resourcePath = resource.getURL().getPath(); - locationPath = StringUtils.cleanPath(this.location.getURL().getPath()); - } - - if (locationPath.equals(resourcePath)) { - return true; - } - locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); - return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath)); - } - - private boolean isInvalidEncodedResourcePath(String resourcePath) { - if (resourcePath.contains("%")) { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... - try { - String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); - if (decodedPath.contains("../") || decodedPath.contains("..\\")) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; + return ResourceHandlerUtils.normalizeInputPath(path); } @Override diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java index 23d0c9186d5..0e67ad132b0 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,6 @@ package org.springframework.web.servlet.resource; import java.io.IOException; -import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -29,14 +28,12 @@ import java.util.StringTokenizer; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.core.log.LogFormatUtils; import org.springframework.http.server.PathContainer; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; -import org.springframework.web.context.support.ServletContextResource; import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UriUtils; import org.springframework.web.util.UrlPathHelper; @@ -214,13 +211,13 @@ public class PathResourceResolver extends AbstractResourceResolver { * @since 4.1.2 */ protected boolean checkResource(Resource resource, Resource location) throws IOException { - if (isResourceUnderLocation(resource, location)) { + if (ResourceHandlerUtils.isResourceUnderLocation(location, resource)) { return true; } Resource[] allowedLocations = getAllowedLocations(); if (allowedLocations != null) { for (Resource current : allowedLocations) { - if (isResourceUnderLocation(resource, current)) { + if (ResourceHandlerUtils.isResourceUnderLocation(current, resource)) { return true; } } @@ -228,38 +225,6 @@ public class PathResourceResolver extends AbstractResourceResolver { return false; } - private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException { - if (resource.getClass() != location.getClass()) { - return false; - } - - String resourcePath; - String locationPath; - - if (resource instanceof UrlResource) { - resourcePath = resource.getURL().toExternalForm(); - locationPath = StringUtils.cleanPath(location.getURL().toString()); - } - else if (resource instanceof ClassPathResource classPathResource) { - resourcePath = classPathResource.getPath(); - locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); - } - else if (resource instanceof ServletContextResource servletContextResource) { - resourcePath = servletContextResource.getPath(); - locationPath = StringUtils.cleanPath(((ServletContextResource) location).getPath()); - } - else { - resourcePath = resource.getURL().getPath(); - locationPath = StringUtils.cleanPath(location.getURL().getPath()); - } - - if (locationPath.equals(resourcePath)) { - return true; - } - locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); - return (resourcePath.startsWith(locationPath) && !isInvalidEncodedPath(resourcePath)); - } - private String encodeOrDecodeIfNecessary(String path, @Nullable HttpServletRequest request, Resource location) { if (request != null) { boolean usesPathPattern = ( @@ -305,22 +270,4 @@ public class PathResourceResolver extends AbstractResourceResolver { this.urlPathHelper != null && this.urlPathHelper.isUrlDecode()); } - private boolean isInvalidEncodedPath(String resourcePath) { - if (resourcePath.contains("%")) { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... - try { - String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); - if (decodedPath.contains("../") || decodedPath.contains("..\\")) { - logger.warn(LogFormatUtils.formatValue( - "Resolved resource path contains encoded \"../\" or \"..\\\": " + resourcePath, -1, true)); - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; - } - } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHandlerUtils.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHandlerUtils.java new file mode 100644 index 00000000000..c88071e9e01 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHandlerUtils.java @@ -0,0 +1,231 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.resource; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.core.log.LogFormatUtils; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.context.support.ServletContextResource; + +/** + * Resource handling utility methods to share common logic between + * {@link ResourceHttpRequestHandler} and {@link org.springframework.web.servlet.function}. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public abstract class ResourceHandlerUtils { + + private static final Log logger = LogFactory.getLog(ResourceHandlerUtils.class); + + + /** + * Normalize the given resource path replacing the following: + *

+ */ + public static String normalizeInputPath(String path) { + path = StringUtils.replace(path, "\\", "/"); + path = cleanDuplicateSlashes(path); + return cleanLeadingSlash(path); + } + + private static String cleanDuplicateSlashes(String path) { + StringBuilder sb = null; + char prev = 0; + for (int i = 0; i < path.length(); i++) { + char curr = path.charAt(i); + try { + if ((curr == '/') && (prev == '/')) { + if (sb == null) { + sb = new StringBuilder(path.substring(0, i)); + } + continue; + } + if (sb != null) { + sb.append(path.charAt(i)); + } + } + finally { + prev = curr; + } + } + return (sb != null ? sb.toString() : path); + } + + private static String cleanLeadingSlash(String path) { + boolean slash = false; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '/') { + slash = true; + } + else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { + if (i == 0 || (i == 1 && slash)) { + return path; + } + return (slash ? "/" + path.substring(i) : path.substring(i)); + } + } + return (slash ? "/" : ""); + } + + /** + * Whether the given input path is invalid as determined by + * {@link #isInvalidPath(String)}. The path is also decoded and the same + * checks are performed again. + */ + public static boolean shouldIgnoreInputPath(String path) { + return (!StringUtils.hasText(path) || isInvalidPath(path) || isInvalidEncodedPath(path)); + } + + /** + * Checks for invalid resource input paths rejecting the following: + * + *

Note: this method assumes that leading, duplicate '/' + * or control characters (e.g. white space) have been trimmed so that the + * path starts predictably with a single '/' or does not have one. + * @param path the path to validate + * @return {@code true} if the path is invalid, {@code false} otherwise + */ + public static boolean isInvalidPath(String path) { + if (path.contains("WEB-INF") || path.contains("META-INF")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path with \"WEB-INF\" or \"META-INF\": [" + path + "]", -1, true)); + } + return true; + } + if (path.contains(":/")) { + String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); + if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path represents URL or has \"url:\" prefix: [" + path + "]", -1, true)); + } + return true; + } + } + if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { + if (logger.isWarnEnabled()) { + logger.warn(LogFormatUtils.formatValue( + "Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]", -1, true)); + } + return true; + } + return false; + } + + /** + * Check whether the given path contains invalid escape sequences. + * @param path the path to validate + * @return {@code true} if the path is invalid, {@code false} otherwise + */ + private static boolean isInvalidEncodedPath(String path) { + if (path.contains("%")) { + try { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars + String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); + if (isInvalidPath(decodedPath)) { + return true; + } + decodedPath = normalizeInputPath(decodedPath); + if (isInvalidPath(decodedPath)) { + return true; + } + } + catch (IllegalArgumentException ex) { + // May not be possible to decode... + } + } + return false; + } + + /** + * Check whether the resource is under the given location. + */ + public static boolean isResourceUnderLocation(Resource location, Resource resource) throws IOException { + if (resource.getClass() != location.getClass()) { + return false; + } + + String resourcePath; + String locationPath; + + if (resource instanceof UrlResource) { + resourcePath = resource.getURL().toExternalForm(); + locationPath = StringUtils.cleanPath(location.getURL().toString()); + } + else if (resource instanceof ClassPathResource classPathResource) { + resourcePath = classPathResource.getPath(); + locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); + } + else if (resource instanceof ServletContextResource servletContextResource) { + resourcePath = servletContextResource.getPath(); + locationPath = StringUtils.cleanPath(((ServletContextResource) location).getPath()); + } + else { + resourcePath = resource.getURL().getPath(); + locationPath = StringUtils.cleanPath(location.getURL().getPath()); + } + + if (locationPath.equals(resourcePath)) { + return true; + } + locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/"); + return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath)); + } + + private static boolean isInvalidEncodedResourcePath(String resourcePath) { + if (resourcePath.contains("%")) { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... + try { + String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8); + if (decodedPath.contains("../") || decodedPath.contains("..\\")) { + logger.warn(LogFormatUtils.formatValue( + "Resolved resource path contains encoded \"../\" or \"..\\\": " + resourcePath, -1, true)); + return true; + } + } + catch (IllegalArgumentException ex) { + // May not be possible to decode... + } + } + return false; + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java index d64a3529ce3..ab4d8de1894 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java @@ -17,9 +17,7 @@ package org.springframework.web.servlet.resource; import java.io.IOException; -import java.net.URLDecoder; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -38,7 +36,6 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; -import org.springframework.core.log.LogFormatUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; @@ -52,7 +49,6 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; import org.springframework.web.HttpRequestHandler; @@ -641,10 +637,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator protected Resource getResource(HttpServletRequest request) throws IOException { String path = getPath(request); path = processPath(path); - if (!StringUtils.hasText(path) || isInvalidPath(path)) { - return null; - } - if (isInvalidEncodedPath(path)) { + if (ResourceHandlerUtils.shouldIgnoreInputPath(path) || isInvalidPath(path)) { return null; } @@ -669,127 +662,19 @@ public class ResourceHttpRequestHandler extends WebContentGenerator /** * Process the given resource path. - *

The default implementation replaces: - *

+ *

By default, this method delegates to {@link ResourceHandlerUtils#normalizeInputPath}. * @since 3.2.12 */ protected String processPath(String path) { - path = StringUtils.replace(path, "\\", "/"); - path = cleanDuplicateSlashes(path); - return cleanLeadingSlash(path); - } - - private String cleanDuplicateSlashes(String path) { - StringBuilder sb = null; - char prev = 0; - for (int i = 0; i < path.length(); i++) { - char curr = path.charAt(i); - try { - if ((curr == '/') && (prev == '/')) { - if (sb == null) { - sb = new StringBuilder(path.substring(0, i)); - } - continue; - } - if (sb != null) { - sb.append(path.charAt(i)); - } - } - finally { - prev = curr; - } - } - return (sb != null ? sb.toString() : path); - } - - private String cleanLeadingSlash(String path) { - boolean slash = false; - for (int i = 0; i < path.length(); i++) { - if (path.charAt(i) == '/') { - slash = true; - } - else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { - if (i == 0 || (i == 1 && slash)) { - return path; - } - return (slash ? "/" + path.substring(i) : path.substring(i)); - } - } - return (slash ? "/" : ""); - } - - /** - * Check whether the given path contains invalid escape sequences. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise - */ - private boolean isInvalidEncodedPath(String path) { - if (path.contains("%")) { - try { - // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars - String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); - if (isInvalidPath(decodedPath)) { - return true; - } - decodedPath = processPath(decodedPath); - if (isInvalidPath(decodedPath)) { - return true; - } - } - catch (IllegalArgumentException ex) { - // May not be possible to decode... - } - } - return false; + return ResourceHandlerUtils.normalizeInputPath(path); } /** - * Identifies invalid resource paths. By default, rejects: - *

- *

Note: this method assumes that leading, duplicate '/' - * or control characters (e.g. white space) have been trimmed so that the - * path starts predictably with a single '/' or does not have one. - * @param path the path to validate - * @return {@code true} if the path is invalid, {@code false} otherwise - * @since 3.0.6 + * Invoked after {@link ResourceHandlerUtils#isInvalidPath(String)} + * to allow subclasses to perform further validation. + *

By default, this method does not perform any validations. */ protected boolean isInvalidPath(String path) { - if (path.contains("WEB-INF") || path.contains("META-INF")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path with \"WEB-INF\" or \"META-INF\": [" + path + "]", -1, true)); - } - return true; - } - if (path.contains(":/")) { - String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); - if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path represents URL or has \"url:\" prefix: [" + path + "]", -1, true)); - } - return true; - } - } - if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { - if (logger.isWarnEnabled()) { - logger.warn(LogFormatUtils.formatValue( - "Path contains \"../\" after call to StringUtils#cleanPath: [" + path + "]", -1, true)); - } - return true; - } return false; }