Skip to content

Commit d86bf8b

Browse files
committed
Align RouterFunctions resource handling
Closes: gh-33434
1 parent c77de8a commit d86bf8b

File tree

2 files changed

+186
-25
lines changed

2 files changed

+186
-25
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/PathResourceLookupFunction.java

+93-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.io.UncheckedIOException;
21+
import java.net.URLDecoder;
2122
import java.nio.charset.StandardCharsets;
2223
import java.util.function.Function;
2324

@@ -30,6 +31,7 @@
3031
import org.springframework.util.Assert;
3132
import org.springframework.util.ResourceUtils;
3233
import org.springframework.util.StringUtils;
34+
import org.springframework.web.util.UriUtils;
3335
import org.springframework.web.util.pattern.PathPattern;
3436
import org.springframework.web.util.pattern.PathPatternParser;
3537

@@ -63,13 +65,17 @@ public Mono<Resource> apply(ServerRequest request) {
6365

6466
pathContainer = this.pattern.extractPathWithinPattern(pathContainer);
6567
String path = processPath(pathContainer.value());
66-
if (path.contains("%")) {
67-
path = StringUtils.uriDecode(path, StandardCharsets.UTF_8);
68+
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
69+
return Mono.empty();
6870
}
69-
if (!StringUtils.hasLength(path) || isInvalidPath(path)) {
71+
if (isInvalidEncodedInputPath(path)) {
7072
return Mono.empty();
7173
}
7274

75+
if (!(this.location instanceof UrlResource)) {
76+
path = UriUtils.decode(path, StandardCharsets.UTF_8);
77+
}
78+
7379
try {
7480
Resource resource = this.location.createRelative(path);
7581
if (resource.isReadable() && isResourceUnderLocation(resource)) {
@@ -84,7 +90,47 @@ public Mono<Resource> apply(ServerRequest request) {
8490
}
8591
}
8692

87-
private String processPath(String path) {
93+
/**
94+
* Process the given resource path.
95+
* <p>The default implementation replaces:
96+
* <ul>
97+
* <li>Backslash with forward slash.
98+
* <li>Duplicate occurrences of slash with a single slash.
99+
* <li>Any combination of leading slash and control characters (00-1F and 7F)
100+
* with a single "/" or "". For example {@code " / // foo/bar"}
101+
* becomes {@code "/foo/bar"}.
102+
* </ul>
103+
*/
104+
protected String processPath(String path) {
105+
path = StringUtils.replace(path, "\\", "/");
106+
path = cleanDuplicateSlashes(path);
107+
return cleanLeadingSlash(path);
108+
}
109+
110+
private String cleanDuplicateSlashes(String path) {
111+
StringBuilder sb = null;
112+
char prev = 0;
113+
for (int i = 0; i < path.length(); i++) {
114+
char curr = path.charAt(i);
115+
try {
116+
if (curr == '/' && prev == '/') {
117+
if (sb == null) {
118+
sb = new StringBuilder(path.substring(0, i));
119+
}
120+
continue;
121+
}
122+
if (sb != null) {
123+
sb.append(path.charAt(i));
124+
}
125+
}
126+
finally {
127+
prev = curr;
128+
}
129+
}
130+
return (sb != null ? sb.toString() : path);
131+
}
132+
133+
private String cleanLeadingSlash(String path) {
88134
boolean slash = false;
89135
for (int i = 0; i < path.length(); i++) {
90136
if (path.charAt(i) == '/') {
@@ -94,8 +140,7 @@ else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
94140
if (i == 0 || (i == 1 && slash)) {
95141
return path;
96142
}
97-
path = slash ? "/" + path.substring(i) : path.substring(i);
98-
return path;
143+
return (slash ? "/" + path.substring(i) : path.substring(i));
99144
}
100145
}
101146
return (slash ? "/" : "");
@@ -117,6 +162,31 @@ private boolean isInvalidPath(String path) {
117162
return false;
118163
}
119164

165+
/**
166+
* Check whether the given path contains invalid escape sequences.
167+
* @param path the path to validate
168+
* @return {@code true} if the path is invalid, {@code false} otherwise
169+
*/
170+
private boolean isInvalidEncodedInputPath(String path) {
171+
if (path.contains("%")) {
172+
try {
173+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
174+
String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8);
175+
if (isInvalidPath(decodedPath)) {
176+
return true;
177+
}
178+
decodedPath = processPath(decodedPath);
179+
if (isInvalidPath(decodedPath)) {
180+
return true;
181+
}
182+
}
183+
catch (IllegalArgumentException ex) {
184+
// May not be possible to decode...
185+
}
186+
}
187+
return false;
188+
}
189+
120190
private boolean isResourceUnderLocation(Resource resource) throws IOException {
121191
if (resource.getClass() != this.location.getClass()) {
122192
return false;
@@ -142,15 +212,24 @@ else if (resource instanceof ClassPathResource classPathResource) {
142212
return true;
143213
}
144214
locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/");
145-
if (!resourcePath.startsWith(locationPath)) {
146-
return false;
147-
}
148-
if (resourcePath.contains("%") && StringUtils.uriDecode(resourcePath, StandardCharsets.UTF_8).contains("../")) {
149-
return false;
150-
}
151-
return true;
215+
return (resourcePath.startsWith(locationPath) && !isInvalidEncodedInputPath(resourcePath));
152216
}
153217

218+
private boolean isInvalidEncodedResourcePath(String resourcePath) {
219+
if (resourcePath.contains("%")) {
220+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars...
221+
try {
222+
String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8);
223+
if (decodedPath.contains("../") || decodedPath.contains("..\\")) {
224+
return true;
225+
}
226+
}
227+
catch (IllegalArgumentException ex) {
228+
// May not be possible to decode...
229+
}
230+
}
231+
return false;
232+
}
154233

155234
@Override
156235
public String toString() {

spring-webmvc/src/main/java/org/springframework/web/servlet/function/PathResourceLookupFunction.java

+93-11
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.io.UncheckedIOException;
21+
import java.net.URLDecoder;
2122
import java.nio.charset.StandardCharsets;
2223
import java.util.Optional;
2324
import java.util.function.Function;
@@ -29,13 +30,16 @@
2930
import org.springframework.util.Assert;
3031
import org.springframework.util.ResourceUtils;
3132
import org.springframework.util.StringUtils;
33+
import org.springframework.web.context.support.ServletContextResource;
34+
import org.springframework.web.util.UriUtils;
3235
import org.springframework.web.util.pattern.PathPattern;
3336
import org.springframework.web.util.pattern.PathPatternParser;
3437

3538
/**
3639
* Lookup function used by {@link RouterFunctions#resources(String, Resource)}.
3740
*
3841
* @author Arjen Poutsma
42+
* @author Rossen Stoyanchev
3943
* @since 5.2
4044
*/
4145
class PathResourceLookupFunction implements Function<ServerRequest, Optional<Resource>> {
@@ -62,13 +66,17 @@ public Optional<Resource> apply(ServerRequest request) {
6266

6367
pathContainer = this.pattern.extractPathWithinPattern(pathContainer);
6468
String path = processPath(pathContainer.value());
65-
if (path.contains("%")) {
66-
path = StringUtils.uriDecode(path, StandardCharsets.UTF_8);
69+
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
70+
return Optional.empty();
6771
}
68-
if (!StringUtils.hasLength(path) || isInvalidPath(path)) {
72+
if (isInvalidEncodedInputPath(path)) {
6973
return Optional.empty();
7074
}
7175

76+
if (!(this.location instanceof UrlResource)) {
77+
path = UriUtils.decode(path, StandardCharsets.UTF_8);
78+
}
79+
7280
try {
7381
Resource resource = this.location.createRelative(path);
7482
if (resource.isReadable() && isResourceUnderLocation(resource)) {
@@ -83,7 +91,47 @@ public Optional<Resource> apply(ServerRequest request) {
8391
}
8492
}
8593

86-
private String processPath(String path) {
94+
/**
95+
* Process the given resource path.
96+
* <p>The default implementation replaces:
97+
* <ul>
98+
* <li>Backslash with forward slash.
99+
* <li>Duplicate occurrences of slash with a single slash.
100+
* <li>Any combination of leading slash and control characters (00-1F and 7F)
101+
* with a single "/" or "". For example {@code " / // foo/bar"}
102+
* becomes {@code "/foo/bar"}.
103+
* </ul>
104+
*/
105+
protected String processPath(String path) {
106+
path = StringUtils.replace(path, "\\", "/");
107+
path = cleanDuplicateSlashes(path);
108+
return cleanLeadingSlash(path);
109+
}
110+
111+
private String cleanDuplicateSlashes(String path) {
112+
StringBuilder sb = null;
113+
char prev = 0;
114+
for (int i = 0; i < path.length(); i++) {
115+
char curr = path.charAt(i);
116+
try {
117+
if ((curr == '/') && (prev == '/')) {
118+
if (sb == null) {
119+
sb = new StringBuilder(path.substring(0, i));
120+
}
121+
continue;
122+
}
123+
if (sb != null) {
124+
sb.append(path.charAt(i));
125+
}
126+
}
127+
finally {
128+
prev = curr;
129+
}
130+
}
131+
return sb != null ? sb.toString() : path;
132+
}
133+
134+
private String cleanLeadingSlash(String path) {
87135
boolean slash = false;
88136
for (int i = 0; i < path.length(); i++) {
89137
if (path.charAt(i) == '/') {
@@ -93,8 +141,7 @@ else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
93141
if (i == 0 || (i == 1 && slash)) {
94142
return path;
95143
}
96-
path = slash ? "/" + path.substring(i) : path.substring(i);
97-
return path;
144+
return (slash ? "/" + path.substring(i) : path.substring(i));
98145
}
99146
}
100147
return (slash ? "/" : "");
@@ -113,6 +160,26 @@ private boolean isInvalidPath(String path) {
113160
return path.contains("..") && StringUtils.cleanPath(path).contains("../");
114161
}
115162

163+
private boolean isInvalidEncodedInputPath(String path) {
164+
if (path.contains("%")) {
165+
try {
166+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
167+
String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8);
168+
if (isInvalidPath(decodedPath)) {
169+
return true;
170+
}
171+
decodedPath = processPath(decodedPath);
172+
if (isInvalidPath(decodedPath)) {
173+
return true;
174+
}
175+
}
176+
catch (IllegalArgumentException ex) {
177+
// May not be possible to decode...
178+
}
179+
}
180+
return false;
181+
}
182+
116183
private boolean isResourceUnderLocation(Resource resource) throws IOException {
117184
if (resource.getClass() != this.location.getClass()) {
118185
return false;
@@ -129,6 +196,10 @@ else if (resource instanceof ClassPathResource classPathResource) {
129196
resourcePath = classPathResource.getPath();
130197
locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath());
131198
}
199+
else if (resource instanceof ServletContextResource servletContextResource) {
200+
resourcePath = servletContextResource.getPath();
201+
locationPath = StringUtils.cleanPath(((ServletContextResource) this.location).getPath());
202+
}
132203
else {
133204
resourcePath = resource.getURL().getPath();
134205
locationPath = StringUtils.cleanPath(this.location.getURL().getPath());
@@ -138,13 +209,24 @@ else if (resource instanceof ClassPathResource classPathResource) {
138209
return true;
139210
}
140211
locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : locationPath + "/");
141-
if (!resourcePath.startsWith(locationPath)) {
142-
return false;
143-
}
144-
return !resourcePath.contains("%") ||
145-
!StringUtils.uriDecode(resourcePath, StandardCharsets.UTF_8).contains("../");
212+
return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath));
146213
}
147214

215+
private boolean isInvalidEncodedResourcePath(String resourcePath) {
216+
if (resourcePath.contains("%")) {
217+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars...
218+
try {
219+
String decodedPath = URLDecoder.decode(resourcePath, StandardCharsets.UTF_8);
220+
if (decodedPath.contains("../") || decodedPath.contains("..\\")) {
221+
return true;
222+
}
223+
}
224+
catch (IllegalArgumentException ex) {
225+
// May not be possible to decode...
226+
}
227+
}
228+
return false;
229+
}
148230

149231
@Override
150232
public String toString() {

0 commit comments

Comments
 (0)