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.
1818
1919import java .io .IOException ;
2020import java .io .UncheckedIOException ;
21+ import java .io .UnsupportedEncodingException ;
22+ import java .net .URLDecoder ;
2123import java .nio .charset .StandardCharsets ;
2224import java .util .Optional ;
2325import java .util .function .Function ;
2931import org .springframework .util .Assert ;
3032import org .springframework .util .ResourceUtils ;
3133import org .springframework .util .StringUtils ;
34+ import org .springframework .web .context .support .ServletContextResource ;
35+ import org .springframework .web .util .UriUtils ;
3236import org .springframework .web .util .pattern .PathPattern ;
3337import 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 */
4146class 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