Skip to content

Commit 8791928

Browse files
committed
Update variable detection in UriComponentsBuilder#encode
This commit better aligns how URI variable placeholders are detected in UriComponentsBuilder#encode (i.e. the pre-encoding of the literal parts of a URI template) and how they are expanded later on. The latter relies on a pattern that stops at the first closing '}' which excludes the possibility for well-formed, nested placeholders other than variables with regex syntax, e.g. "{year:\d{1,4}}". UriComponentsBuilder#encode now also stops at the first closing '}' and further ensures the placeholder is not empty and that it has '{' before deciding to treat it as a URI variable. Closes gh-26466
1 parent c9147c4 commit 8791928

File tree

5 files changed

+90
-26
lines changed

5 files changed

+90
-26
lines changed

spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2021 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.
@@ -767,44 +767,47 @@ private static class UriTemplateEncoder implements BiFunction<String, Type, Stri
767767

768768
private final StringBuilder output = new StringBuilder();
769769

770+
private boolean variableWithNameAndRegex;
770771

771772
public UriTemplateEncoder(Charset charset) {
772773
this.charset = charset;
773774
}
774775

775-
776776
@Override
777777
public String apply(String source, Type type) {
778-
779-
// Only URI variable (nothing to encode)..
780-
if (source.length() > 1 && source.charAt(0) == '{' && source.charAt(source.length() -1) == '}') {
778+
// URI variable only?
779+
if (isUriVariable(source)) {
781780
return source;
782781
}
783-
784-
// Only literal (encode full source)..
782+
// Literal template only?
785783
if (source.indexOf('{') == -1) {
786784
return encodeUriComponent(source, this.charset, type);
787785
}
788-
789-
// Mixed literal parts and URI variables, maybe (encode literal parts only)..
790786
int level = 0;
791787
clear(this.currentLiteral);
792788
clear(this.currentVariable);
793789
clear(this.output);
794790
for (int i = 0; i < source.length(); i++) {
795791
char c = source.charAt(i);
792+
if (c == ':' && level == 1) {
793+
this.variableWithNameAndRegex = true;
794+
}
796795
if (c == '{') {
797796
level++;
798797
if (level == 1) {
799-
encodeAndAppendCurrentLiteral(type);
798+
append(this.currentLiteral, true, type);
800799
}
801800
}
802801
if (c == '}' && level > 0) {
803802
level--;
804803
this.currentVariable.append('}');
805804
if (level == 0) {
806-
this.output.append(this.currentVariable);
807-
clear(this.currentVariable);
805+
boolean encode = !isUriVariable(this.currentVariable);
806+
append(this.currentVariable, encode, type);
807+
}
808+
else if (!this.variableWithNameAndRegex) {
809+
append(this.currentVariable, true, type);
810+
level = 0;
808811
}
809812
}
810813
else if (level > 0) {
@@ -817,13 +820,38 @@ else if (level > 0) {
817820
if (level > 0) {
818821
this.currentLiteral.append(this.currentVariable);
819822
}
820-
encodeAndAppendCurrentLiteral(type);
823+
append(this.currentLiteral, true, type);
821824
return this.output.toString();
822825
}
823826

824-
private void encodeAndAppendCurrentLiteral(Type type) {
825-
this.output.append(encodeUriComponent(this.currentLiteral.toString(), this.charset, type));
826-
clear(this.currentLiteral);
827+
/**
828+
* Whether the given String is a single URI variable that can be
829+
* expanded. It must have '{' and '}' surrounding non-empty text and no
830+
* nested placeholders unless it is a variable with regex syntax,
831+
* e.g. {@code "/{year:\d{1,4}}"}.
832+
*/
833+
private boolean isUriVariable(CharSequence source) {
834+
if (source.length() < 2 || source.charAt(0) != '{' || source.charAt(source.length() -1) != '}') {
835+
return false;
836+
}
837+
boolean hasText = false;
838+
for (int i = 1; i < source.length() - 1; i++) {
839+
char c = source.charAt(i);
840+
if (c == ':' && i > 1) {
841+
return true;
842+
}
843+
if (c == '{' || c == '}') {
844+
return false;
845+
}
846+
hasText = (hasText || !Character.isWhitespace(c));
847+
}
848+
return hasText;
849+
}
850+
851+
private void append(StringBuilder sb, boolean encode, Type type) {
852+
this.output.append(encode ? encodeUriComponent(sb.toString(), this.charset, type) : sb);
853+
clear(sb);
854+
this.variableWithNameAndRegex = false;
827855
}
828856

829857
private void clear(StringBuilder sb) {

spring-web/src/main/java/org/springframework/web/util/UriComponents.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2021 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.
@@ -201,10 +201,11 @@ public final UriComponents expand(UriTemplateVariables uriVariables) {
201201

202202
/**
203203
* Concatenate all URI components to return the fully formed URI String.
204-
* <p>This method does nothing more than a simple concatenation based on
205-
* current values. That means it could produce different results if invoked
206-
* before vs after methods that can change individual values such as
207-
* {@code encode}, {@code expand}, or {@code normalize}.
204+
* <p>This method amounts to simple String concatenation of the current
205+
* URI component values and as such the result may contain illegal URI
206+
* characters, for example if URI variables have not been expanded or if
207+
* encoding has not been applied via {@link UriComponentsBuilder#encode()}
208+
* or {@link #encode()}.
208209
*/
209210
public abstract String toUriString();
210211

spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2021 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.
@@ -412,12 +412,15 @@ public static UriComponentsBuilder fromOriginHeader(String origin) {
412412
* also escaping characters with reserved meaning.
413413
* <p>For most cases, this method is more likely to give the expected result
414414
* because in treats URI variables as opaque data to be fully encoded, while
415-
* {@link UriComponents#encode()} is useful only if intentionally expanding
416-
* URI variables that contain reserved characters.
415+
* {@link UriComponents#encode()} is useful when intentionally expanding URI
416+
* variables that contain reserved characters.
417417
* <p>For example ';' is legal in a path but has reserved meaning. This
418418
* method replaces ";" with "%3B" in URI variables but not in the URI
419419
* template. By contrast, {@link UriComponents#encode()} never replaces ";"
420420
* since it is a legal character in a path.
421+
* <p>When not expanding URI variables at all, prefer use of
422+
* {@link UriComponents#encode()} since that will also encode anything that
423+
* incidentally looks like a URI variable.
421424
* @since 5.0.8
422425
*/
423426
public final UriComponentsBuilder encode() {

spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.List;
2626
import java.util.Map;
2727
import java.util.Optional;
28+
import java.util.function.BiConsumer;
2829

2930
import org.junit.jupiter.api.Test;
3031

@@ -1037,6 +1038,35 @@ void testDeepClone() {
10371038
assertThat(result1.getSchemeSpecificPart()).isEqualTo(null);
10381039
}
10391040

1041+
@Test // gh-26466
1042+
void encodeTemplateWithInvalidPlaceholderSyntax() {
1043+
1044+
BiConsumer<String, String> tester = (in, out) ->
1045+
assertThat(UriComponentsBuilder.fromUriString(in).encode().toUriString()).isEqualTo(out);
1046+
1047+
// empty
1048+
tester.accept("{}", "%7B%7D");
1049+
tester.accept("{ \t}", "%7B%20%09%7D");
1050+
tester.accept("/a{}b", "/a%7B%7Db");
1051+
tester.accept("/a{ \t}b", "/a%7B%20%09%7Db");
1052+
1053+
// nested, matching
1054+
tester.accept("{foo{}}", "%7Bfoo%7B%7D%7D");
1055+
tester.accept("{foo{bar}baz}", "%7Bfoo%7Bbar%7Dbaz%7D");
1056+
tester.accept("/a{foo{}}b", "/a%7Bfoo%7B%7D%7Db");
1057+
tester.accept("/a{foo{bar}baz}b", "/a%7Bfoo%7Bbar%7Dbaz%7Db");
1058+
1059+
// mismatched
1060+
tester.accept("{foo{{}", "%7Bfoo%7B%7B%7D");
1061+
tester.accept("{foo}}", "{foo}%7D");
1062+
tester.accept("/a{foo{{}bar", "/a%7Bfoo%7B%7B%7Dbar");
1063+
tester.accept("/a{foo}}b", "/a{foo}%7Db");
1064+
1065+
// variable with regex
1066+
tester.accept("{year:\\d{1,4}}", "{year:\\d{1,4}}");
1067+
tester.accept("/a{year:\\d{1,4}}b", "/a{year:\\d{1,4}}b");
1068+
}
1069+
10401070
@Test // SPR-11856
10411071
void fromHttpRequestForwardedHeader() {
10421072
MockHttpServletRequest request = new MockHttpServletRequest();

src/docs/asciidoc/web/web-uris.adoc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,10 @@ TIP: Consider ";", which is legal in a path but has reserved meaning. The first
214214
replaces ";", since it is a legal character in a path.
215215

216216
For most cases, the first option is likely to give the expected result, because it treats URI
217-
variables as opaque data to be fully encoded, while option 2 is useful only if
218-
URI variables intentionally contain reserved characters.
217+
variables as opaque data to be fully encoded, while the second option is useful if URI
218+
variables do intentionally contain reserved characters. The second option is also useful
219+
when not expanding URI variables at all since that will also encode anything that
220+
incidentally looks like a URI variable.
219221

220222
The following example uses the first option:
221223

0 commit comments

Comments
 (0)