Skip to content

Commit 0f83dc1

Browse files
committed
Align RouterFunctions resource handling
Closes: spring-projectsgh-33434
1 parent 8a44eaa commit 0f83dc1

File tree

2 files changed

+200
-28
lines changed

2 files changed

+200
-28
lines changed

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

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 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,8 @@
1818

1919
import java.io.IOException;
2020
import java.io.UncheckedIOException;
21+
import java.io.UnsupportedEncodingException;
22+
import java.net.URLDecoder;
2123
import java.nio.charset.StandardCharsets;
2224
import java.util.function.Function;
2325

@@ -30,6 +32,7 @@
3032
import org.springframework.util.Assert;
3133
import org.springframework.util.ResourceUtils;
3234
import org.springframework.util.StringUtils;
35+
import org.springframework.web.util.UriUtils;
3336
import org.springframework.web.util.pattern.PathPattern;
3437
import org.springframework.web.util.pattern.PathPatternParser;
3538

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

6467
pathContainer = this.pattern.extractPathWithinPattern(pathContainer);
6568
String path = processPath(pathContainer.value());
66-
if (path.contains("%")) {
67-
path = StringUtils.uriDecode(path, StandardCharsets.UTF_8);
69+
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
70+
return Mono.empty();
6871
}
69-
if (!StringUtils.hasLength(path) || isInvalidPath(path)) {
72+
if (isInvalidEncodedInputPath(path)) {
7073
return Mono.empty();
7174
}
7275

76+
if (!(this.location instanceof UrlResource)) {
77+
path = UriUtils.decode(path, StandardCharsets.UTF_8);
78+
}
79+
7380
try {
7481
Resource resource = this.location.createRelative(path);
7582
if (resource.isReadable() && isResourceUnderLocation(resource)) {
@@ -84,7 +91,47 @@ public Mono<Resource> apply(ServerRequest request) {
8491
}
8592
}
8693

87-
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) {
88135
boolean slash = false;
89136
for (int i = 0; i < path.length(); i++) {
90137
if (path.charAt(i) == '/') {
@@ -94,8 +141,7 @@ else if (path.charAt(i) > ' ' && path.charAt(i) != 127) {
94141
if (i == 0 || (i == 1 && slash)) {
95142
return path;
96143
}
97-
path = slash ? "/" + path.substring(i) : path.substring(i);
98-
return path;
144+
return (slash ? "/" + path.substring(i) : path.substring(i));
99145
}
100146
}
101147
return (slash ? "/" : "");
@@ -111,8 +157,33 @@ private boolean isInvalidPath(String path) {
111157
return true;
112158
}
113159
}
114-
if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) {
115-
return true;
160+
return path.contains("..") && StringUtils.cleanPath(path).contains("../");
161+
}
162+
163+
/**
164+
* Check whether the given path contains invalid escape sequences.
165+
* @param path the path to validate
166+
* @return {@code true} if the path is invalid, {@code false} otherwise
167+
*/
168+
private boolean isInvalidEncodedInputPath(String path) {
169+
if (path.contains("%")) {
170+
try {
171+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
172+
String decodedPath = URLDecoder.decode(path, "UTF-8");
173+
if (isInvalidPath(decodedPath)) {
174+
return true;
175+
}
176+
decodedPath = processPath(decodedPath);
177+
if (isInvalidPath(decodedPath)) {
178+
return true;
179+
}
180+
}
181+
catch (IllegalArgumentException ex) {
182+
// May not be possible to decode...
183+
}
184+
catch (UnsupportedEncodingException ex) {
185+
throw new RuntimeException(ex);
186+
}
116187
}
117188
return false;
118189
}
@@ -142,15 +213,27 @@ else if (resource instanceof ClassPathResource) {
142213
return true;
143214
}
144215
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;
216+
return (resourcePath.startsWith(locationPath) && !isInvalidEncodedInputPath(resourcePath));
152217
}
153218

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

155238
@Override
156239
public String toString() {

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

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2023 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,8 @@
1818

1919
import java.io.IOException;
2020
import java.io.UncheckedIOException;
21+
import java.io.UnsupportedEncodingException;
22+
import java.net.URLDecoder;
2123
import java.nio.charset.StandardCharsets;
2224
import java.util.Optional;
2325
import java.util.function.Function;
@@ -29,13 +31,16 @@
2931
import org.springframework.util.Assert;
3032
import org.springframework.util.ResourceUtils;
3133
import org.springframework.util.StringUtils;
34+
import org.springframework.web.context.support.ServletContextResource;
35+
import org.springframework.web.util.UriUtils;
3236
import org.springframework.web.util.pattern.PathPattern;
3337
import org.springframework.web.util.pattern.PathPatternParser;
3438

3539
/**
3640
* Lookup function used by {@link RouterFunctions#resources(String, Resource)}.
3741
*
3842
* @author Arjen Poutsma
43+
* @author Rossen Stoyanchev
3944
* @since 5.2
4045
*/
4146
class PathResourceLookupFunction implements Function<ServerRequest, Optional<Resource>> {
@@ -62,13 +67,17 @@ public Optional<Resource> apply(ServerRequest request) {
6267

6368
pathContainer = this.pattern.extractPathWithinPattern(pathContainer);
6469
String path = processPath(pathContainer.value());
65-
if (path.contains("%")) {
66-
path = StringUtils.uriDecode(path, StandardCharsets.UTF_8);
70+
if (!StringUtils.hasText(path) || isInvalidPath(path)) {
71+
return Optional.empty();
6772
}
68-
if (!StringUtils.hasLength(path) || isInvalidPath(path)) {
73+
if (isInvalidEncodedInputPath(path)) {
6974
return Optional.empty();
7075
}
7176

77+
if (!(this.location instanceof UrlResource)) {
78+
path = UriUtils.decode(path, StandardCharsets.UTF_8);
79+
}
80+
7281
try {
7382
Resource resource = this.location.createRelative(path);
7483
if (resource.isReadable() && isResourceUnderLocation(resource)) {
@@ -83,7 +92,47 @@ public Optional<Resource> apply(ServerRequest request) {
8392
}
8493
}
8594

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

164+
private boolean isInvalidEncodedInputPath(String path) {
165+
if (path.contains("%")) {
166+
try {
167+
// Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars
168+
String decodedPath = URLDecoder.decode(path, "UTF-8");
169+
if (isInvalidPath(decodedPath)) {
170+
return true;
171+
}
172+
decodedPath = processPath(decodedPath);
173+
if (isInvalidPath(decodedPath)) {
174+
return true;
175+
}
176+
}
177+
catch (IllegalArgumentException ex) {
178+
// May not be possible to decode...
179+
}
180+
catch (UnsupportedEncodingException ex) {
181+
throw new RuntimeException(ex);
182+
}
183+
}
184+
return false;
185+
}
186+
116187
private boolean isResourceUnderLocation(Resource resource) throws IOException {
117188
if (resource.getClass() != this.location.getClass()) {
118189
return false;
@@ -129,6 +200,10 @@ else if (resource instanceof ClassPathResource) {
129200
resourcePath = ((ClassPathResource) resource).getPath();
130201
locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath());
131202
}
203+
else if (resource instanceof ServletContextResource) {
204+
resourcePath = ((ServletContextResource) resource).getPath();
205+
locationPath = StringUtils.cleanPath(((ServletContextResource) this.location).getPath());
206+
}
132207
else {
133208
resourcePath = resource.getURL().getPath();
134209
locationPath = StringUtils.cleanPath(this.location.getURL().getPath());
@@ -138,13 +213,27 @@ else if (resource instanceof ClassPathResource) {
138213
return true;
139214
}
140215
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("../");
216+
return (resourcePath.startsWith(locationPath) && !isInvalidEncodedResourcePath(resourcePath));
146217
}
147218

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

149238
@Override
150239
public String toString() {

0 commit comments

Comments
 (0)