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 aa314b623565..20c841538ef1 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 @@ 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 @@ public Mono apply(ServerRequest request) { } pathContainer = this.pattern.extractPathWithinPattern(pathContainer); - String path = processPath(pathContainer.value()); - if (!StringUtils.hasText(path) || isInvalidPath(path)) { + String path = ResourceHandlerUtils.normalizeInputPath(pathContainer.value()); + if (ResourceHandlerUtils.shouldIgnoreInputPath(path)) { return Mono.empty(); } - if (isInvalidEncodedInputPath(path)) { - return Mono.empty(); - } - - if (!(this.location instanceof UrlResource)) { - path = UriUtils.decode(path, StandardCharsets.UTF_8); - } try { - Resource resource = this.location.createRelative(path); - if (resource.isReadable() && isResourceUnderLocation(resource)) { + Resource resource = ResourceHandlerUtils.createRelativeResource(this.location, path); + if (resource.isReadable() && ResourceHandlerUtils.isResourceUnderLocation(this.location, resource)) { return Mono.just(resource); } else { @@ -90,147 +77,6 @@ public Mono apply(ServerRequest request) { } } - /** - * Process the given resource path. - *

The 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 87044b1fe148..c9b7c56fcf1f 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 @@ private Mono getResource(String resourcePath, List */ 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 @@ else if (logger.isWarnEnabled()) { * @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 000000000000..0c8e148f43af --- /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: + *
    + *
  • Backslash with forward slash. + *
  • Duplicate occurrences of slash with a single slash. + *
  • Any combination of leading slash and control characters (00-1F and 7F) + * with a single "/" or "". For example {@code " / // foo/bar"} + * becomes {@code "/foo/bar"}. + *
+ */ + 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: + *
    + *
  • Paths that contain "WEB-INF" or "META-INF" + *
  • Paths that contain "../" after a call to + * {@link StringUtils#cleanPath}. + *
  • Paths that represent a {@link ResourceUtils#isUrl + * valid URL} or would represent one after the leading slash is removed. + *
+ *

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 3f4721a5b02d..a2f611cd4f59 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.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.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 Mono handle(ServerWebExchange exchange) { 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 @@ private String getResourcePath(ServerWebExchange exchange) { /** * Process the given resource path. - *

The default implementation replaces: - *

    - *
  • Backslash with forward slash. - *
  • Duplicate occurrences of slash with a single slash. - *
  • Any combination of leading slash and control characters (00-1F and 7F) - * with a single "/" or "". For example {@code " / // foo/bar"} - * becomes {@code "/foo/bar"}. - *
+ *

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: - *

    - *
  • Paths that contain "WEB-INF" or "META-INF" - *
  • Paths that contain "../" after a call to - * {@link StringUtils#cleanPath}. - *
  • Paths that represent a {@link ResourceUtils#isUrl - * valid URL} or would represent one after the leading slash is removed. - *
- *

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 e9c700f10f75..80dd62127508 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 @@ 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 @@ public Optional apply(ServerRequest request) { pathContainer = this.pattern.extractPathWithinPattern(pathContainer); String path = processPath(pathContainer.value()); - if (!StringUtils.hasText(path) || isInvalidPath(path)) { - return Optional.empty(); - } - if (isInvalidEncodedInputPath(path)) { + if (ResourceHandlerUtils.shouldIgnoreInputPath(path)) { return Optional.empty(); } @@ -79,7 +72,7 @@ public Optional apply(ServerRequest request) { try { Resource resource = this.location.createRelative(path); - if (resource.isReadable() && isResourceUnderLocation(resource)) { + if (resource.isReadable() && ResourceHandlerUtils.isResourceUnderLocation(this.location, resource)) { return Optional.of(resource); } else { @@ -93,139 +86,10 @@ public Optional apply(ServerRequest request) { /** * Process the given resource path. - *

The default implementation replaces: - *

    - *
  • Backslash with forward slash. - *
  • Duplicate occurrences of slash with a single slash. - *
  • Any combination of leading slash and control characters (00-1F and 7F) - * with a single "/" or "". For example {@code " / // foo/bar"} - * becomes {@code "/foo/bar"}. - *
+ *

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 23d0c9186d5b..0e67ad132b0a 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 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 @@ else if (logger.isWarnEnabled()) { * @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 @@ protected boolean checkResource(Resource resource, Resource location) throws IOE 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 @@ private boolean shouldEncodeRelativePath(Resource location, boolean usesPathPatt 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 000000000000..c88071e9e012 --- /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: + *

    + *
  • Backslash with forward slash. + *
  • Duplicate occurrences of slash with a single slash. + *
  • Any combination of leading slash and control characters (00-1F and 7F) + * with a single "/" or "". For example {@code " / // foo/bar"} + * becomes {@code "/foo/bar"}. + *
+ */ + 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: + *
    + *
  • Paths that contain "WEB-INF" or "META-INF" + *
  • Paths that contain "../" after a call to + * {@link org.springframework.util.StringUtils#cleanPath}. + *
  • Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl + * valid URL} or would represent one after the leading slash is removed. + *
+ *

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 d64a3529ce3c..ab4d8de1894d 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.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.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 void handleRequest(HttpServletRequest request, HttpServletResponse respon 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 @@ private static String getPath(HttpServletRequest request) { /** * Process the given resource path. - *

The default implementation replaces: - *

    - *
  • Backslash with forward slash. - *
  • Duplicate occurrences of slash with a single slash. - *
  • Any combination of leading slash and control characters (00-1F and 7F) - * with a single "/" or "". For example {@code " / // foo/bar"} - * becomes {@code "/foo/bar"}. - *
+ *

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: - *

    - *
  • Paths that contain "WEB-INF" or "META-INF" - *
  • Paths that contain "../" after a call to - * {@link org.springframework.util.StringUtils#cleanPath}. - *
  • Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl - * valid URL} or would represent one after the leading slash is removed. - *
- *

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; }