Reject unsafe resource handling locations

As of gh-36692, Spring logs a WARN message when an unsafe resource
handling location is configured. This change now rejects entirely such
setups by failing before the application starts up.

Closes gh-36695
This commit is contained in:
Brian Clozel
2026-04-23 11:29:47 +02:00
parent 0d14aa5856
commit 7a5844f1ce
12 changed files with 264 additions and 45 deletions
@@ -19,7 +19,6 @@ package org.springframework.web.reactive.resource;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -69,17 +68,15 @@ public abstract class ResourceHandlerUtils {
}
else if (location instanceof ClassPathResource classPathResource) {
path = classPathResource.getPath();
if (path.isEmpty() || "/".equals(path)) {
logger.warn("Resource location '" + location + "' is considered unsafe " +
"and should not be used as it provides access to the entire classpath.");
}
Assert.isTrue(!path.isEmpty() && !"/".equals(path),
() -> "Resource location '" + location + "' is considered unsafe " +
"and cannot be used as it provides access to the entire classpath.");
}
else if (location instanceof ContextResource contextResource) {
path = contextResource.getPathWithinContext();
if ("/".equals(path)) {
logger.warn("Resource location '" + location + "' is considered unsafe " +
"and should not be used as it provides access to the root servlet context.");
}
Assert.isTrue(!"/".equals(path),
() -> "Resource location '" + location + "' is considered unsafe " +
"and cannot be used as it provides access to the root servlet context.");
}
else if (location instanceof UrlResource) {
path = location.getURL().toExternalForm();
@@ -176,7 +173,6 @@ public abstract class ResourceHandlerUtils {
/**
* Checks for invalid resource input paths rejecting the following:
* <ul>
* <li>Paths that contain "WEB-INF" or "META-INF"
* <li>Paths that contain "../" after a call to
* {@link StringUtils#cleanPath}.
* <li>Paths that represent a {@link ResourceUtils#isUrl
@@ -189,14 +185,6 @@ public abstract class ResourceHandlerUtils {
* @return {@code true} if the path is invalid, {@code false} otherwise
*/
public static boolean isInvalidPath(String path) {
String pathLowerCase = path.toLowerCase(Locale.ROOT);
if (pathLowerCase.contains("web-inf") || pathLowerCase.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:")) {
@@ -0,0 +1,156 @@
/*
* Copyright 2002-present 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.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.ContextResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.UrlResource;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link ResourceHandlerUtils}.
*/
class ResourceHandlerUtilsTests {
@Test
@SuppressWarnings("removal")
void assertResourceLocation() throws Exception {
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new FileSystemResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new PathResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new UrlResource("file:/test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new TestContextResource("/test/")));
}
@Test
void assertResourceLocationShouldRejectNull() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(null))
.withMessage("Resource location must not be null");
}
@Test
void assertResourceLocationShouldRejectNotEndWithSlash() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("test")))
.withMessageContaining("Resource location does not end with slash");
}
@Test
void assertResourceLocationShouldRejectUnsafeClassPathResource() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("")))
.withMessageContaining("is considered unsafe");
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("/")))
.withMessageContaining("is considered unsafe");
}
@Test
void assertResourceLocationShouldRejectUnsafeContextResource() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new TestContextResource("/")))
.withMessageContaining("is considered unsafe");
}
private static class TestContextResource implements ContextResource {
private final String path;
TestContextResource(String path) {
this.path = path;
}
@Override
public String getPathWithinContext() {
return this.path;
}
@Override
public boolean exists() {
return false;
}
@Override
public URL getURL() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public URI getURI() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public File getFile() throws IOException {
throw new UnsupportedOperationException();
}
@Override
public long contentLength() throws IOException {
return 0;
}
@Override
public long lastModified() throws IOException {
return 0;
}
@Override
public org.springframework.core.io.Resource createRelative(String relativePath) throws IOException {
throw new UnsupportedOperationException();
}
@Override
public String getFilename() {
return null;
}
@Override
public String getDescription() {
return "TestContextResource";
}
@Override
public InputStream getInputStream() throws IOException {
throw new UnsupportedOperationException();
}
}
}
@@ -19,7 +19,6 @@ package org.springframework.web.servlet.resource;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -70,17 +69,15 @@ public abstract class ResourceHandlerUtils {
}
else if (location instanceof ClassPathResource classPathResource) {
path = classPathResource.getPath();
if (path.isEmpty() || "/".equals(path)) {
logger.warn("Resource location '" + location + "' is considered unsafe " +
"and should not be used as it provides access to the entire classpath.");
}
Assert.isTrue(!path.isEmpty() && !"/".equals(path),
() -> "Resource location '" + location + "' is considered unsafe " +
"and cannot be used as it provides access to the entire classpath.");
}
else if (location instanceof ContextResource contextResource) {
path = contextResource.getPathWithinContext();
if ("/".equals(path)) {
logger.warn("Resource location '" + location + "' is considered unsafe " +
"and should not be used as it provides access to the root servlet context.");
}
Assert.isTrue(!"/".equals(path),
() -> "Resource location '" + location + "' is considered unsafe " +
"and cannot be used as it provides access to the root servlet context.");
}
else if (location instanceof UrlResource) {
path = location.getURL().toExternalForm();
@@ -177,7 +174,6 @@ public abstract class ResourceHandlerUtils {
/**
* Checks for invalid resource input paths rejecting the following:
* <ul>
* <li>Paths that contain "WEB-INF" or "META-INF"
* <li>Paths that contain "../" after a call to
* {@link org.springframework.util.StringUtils#cleanPath}.
* <li>Paths that represent a {@link org.springframework.util.ResourceUtils#isUrl
@@ -190,14 +186,6 @@ public abstract class ResourceHandlerUtils {
* @return {@code true} if the path is invalid, {@code false} otherwise
*/
public static boolean isInvalidPath(String path) {
String pathLowerCase = path.toLowerCase(Locale.ROOT);
if (pathLowerCase.contains("web-inf") || pathLowerCase.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:")) {
@@ -239,7 +239,7 @@ class DelegatingWebMvcConfigurationTests {
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**").addResourceLocations("/");
registry.addResourceHandler("/resources/**").addResourceLocations("/static");
}
};
@@ -304,7 +304,7 @@ class DelegatingWebMvcConfigurationTests {
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**").addResourceLocations("/");
registry.addResourceHandler("/resources/**").addResourceLocations("/static");
}
};
@@ -0,0 +1,87 @@
/*
* Copyright 2002-present 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 org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.UrlResource;
import org.springframework.web.context.support.ServletContextResource;
import org.springframework.web.testfixture.servlet.MockServletContext;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link ResourceHandlerUtils}.
*/
class ResourceHandlerUtilsTests {
@Test
@SuppressWarnings("removal")
void assertResourceLocation() throws Exception {
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new FileSystemResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new PathResource("test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new UrlResource("file:/test/")));
assertThatNoException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ServletContextResource(new MockServletContext(), "/test/")));
}
@Test
void assertResourceLocationShouldRejectNull() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(null))
.withMessage("Resource location must not be null");
}
@Test
void assertResourceLocationShouldRejectNotEndWithSlash() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("test")))
.withMessageContaining("Resource location does not end with slash");
}
@Test
void assertResourceLocationShouldRejectUnsafeClassPathResource() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("")))
.withMessageContaining("is considered unsafe");
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ClassPathResource("/")))
.withMessageContaining("is considered unsafe");
}
@Test
void assertResourceLocationShouldRejectUnsafeContextResource() {
assertThatIllegalArgumentException().isThrownBy(() ->
ResourceHandlerUtils.assertResourceLocation(new ServletContextResource(new MockServletContext(), "/")))
.withMessageContaining("is considered unsafe");
}
}
@@ -12,6 +12,6 @@
</mvc:annotation-driven>
<mvc:view-controller path="/foo"/>
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/"/>
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/"/>
</beans>
@@ -12,6 +12,6 @@
</mvc:annotation-driven>
<mvc:view-controller path="/foo"/>
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/"/>
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/"/>
</beans>
@@ -5,7 +5,7 @@
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/" />
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/" />
<mvc:annotation-driven>
<mvc:path-matching
@@ -5,7 +5,7 @@
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/">
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/">
<mvc:cache-control max-age="3600" s-maxage="1800" cache-public="true"/>
<mvc:resource-chain resource-cache="false" auto-registration="false">
<mvc:resolvers>
@@ -27,7 +27,7 @@
</property>
</bean>
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/, [charset=ISO-8859-1]${location}">
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/, [charset=ISO-8859-1]${location}">
<mvc:resource-chain resource-cache="true" cache-manager="resourceCache" cache-name="test-resource-cache">
<mvc:resolvers>
<mvc:version-resolver>
@@ -5,6 +5,6 @@
xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/" cache-period="3600" order="5"/>
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/" cache-period="3600" order="5"/>
</beans>
@@ -6,6 +6,6 @@
http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd">
<mvc:annotation-driven />
<mvc:resources mapping="/resources/**" location="/, classpath:/META-INF/" />
<mvc:resources mapping="/resources/**" location="/static, classpath:/META-INF/" />
</beans>