Skip to content

Commit c5b0ca1

Browse files
authored
Preserve malformed percent sequences in URI decoding (4.x) (helidon-io#11910) (helidon-io#12029)
* Preserve malformed URI percent sequences * Preserve malformed query percent escapes * Preserve adjacent malformed URI escapes * Cover adjacent incomplete URI escapes (cherry picked from commit 08de312)
1 parent 2168748 commit c5b0ca1

5 files changed

Lines changed: 116 additions & 12 deletions

File tree

common/uri/src/main/java/io/helidon/common/uri/UriEncoding.java

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2026 Oracle and/or its affiliates.
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.
@@ -46,6 +46,7 @@ private UriEncoding() {
4646
* <p>
4747
* Percent characters {@code "%s"} found between brackets {@code "[]"} are not decoded to support IPv6 literal.
4848
* E.g. {@code http://[fe80::1%lo0]:8080}.
49+
* Malformed percent-encoded sequences are preserved literally.
4950
* <p>
5051
* See <a href="https://tools.ietf.org/html/rfc6874#section-2">RFC 6874, section 2.</a>
5152
*
@@ -58,6 +59,7 @@ public static String decodeUri(String uriSegment) {
5859

5960
/**
6061
* Decode a URI query.
62+
* Malformed percent-encoded sequences are preserved literally.
6163
*
6264
* @param uriQuery URI query with percent encoding
6365
* @return decoded string
@@ -169,17 +171,34 @@ private static String decode(String string, boolean ignorePercentInBrackets) {
169171
c = string.charAt(i);
170172
continue;
171173
}
172-
bb.clear();
173-
while (true) {
174-
bb.put(decode(string.charAt(++i), string.charAt(++i)));
175-
if (++i >= len) {
174+
if (!isPercentEncoded(string, i, len)) {
175+
int malformedEnd = malformedPercentEnd(string, i, len);
176+
while (i < malformedEnd) {
177+
c = string.charAt(i);
178+
if (c == '[') {
179+
betweenBrackets = true;
180+
} else if (betweenBrackets && c == ']') {
181+
betweenBrackets = false;
182+
}
183+
sb.append(c);
184+
i++;
185+
}
186+
if (i >= len) {
176187
break;
177188
}
178189
c = string.charAt(i);
179-
if (c != '%') {
190+
continue;
191+
}
192+
193+
bb.clear();
194+
do {
195+
bb.put(decode(string.charAt(i + 1), string.charAt(i + 2)));
196+
i += 3;
197+
if (i >= len) {
180198
break;
181199
}
182-
}
200+
c = string.charAt(i);
201+
} while (c == '%' && isPercentEncoded(string, i, len));
183202
bb.flip();
184203

185204
CharBuffer cb = StandardCharsets.UTF_8.decode(bb);
@@ -207,6 +226,22 @@ private static int decode(char c) {
207226
return -1;
208227
}
209228

229+
private static boolean isPercentEncoded(String string, int index, int len) {
230+
return index + 2 < len
231+
&& isHexCharacter(string.charAt(index + 1))
232+
&& isHexCharacter(string.charAt(index + 2));
233+
}
234+
235+
private static int malformedPercentEnd(String string, int index, int len) {
236+
int end = Math.min(index + 3, len);
237+
for (int i = index + 1; i < end; i++) {
238+
if (string.charAt(i) == '%') {
239+
return i;
240+
}
241+
}
242+
return end;
243+
}
244+
210245
private static void appendUTF8EncodedCharacter(StringBuilder sb, int codePoint) {
211246
CharBuffer chars = CharBuffer.wrap(Character.toChars(codePoint));
212247
ByteBuffer bytes = StandardCharsets.UTF_8.encode(chars);

common/uri/src/main/java/io/helidon/common/uri/UriQuery.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2026 Oracle and/or its affiliates.
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.
@@ -32,6 +32,8 @@ public interface UriQuery extends Parameters {
3232
/**
3333
* Create a new HTTP query from the query string.
3434
* This method does not validate the raw query against specification.
35+
* Malformed percent-encoded sequences are preserved literally when parameter names and values are decoded.
36+
* Use {@link #create(String, boolean) create(query, true)} for strict query validation.
3537
*
3638
* @param query raw query string
3739
* @return HTTP query instance

common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteable.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2026 Oracle and/or its affiliates.
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.
@@ -89,6 +89,9 @@ static UriQueryWriteable create() {
8989
* <p>
9090
* This documentation (and behavior) has been changed, as we cannot create a proper query from {@code decoded} values,
9191
* as these may contain characters used to split the query.
92+
* Malformed percent-encoded sequences are preserved literally when parameter names and values are decoded.
93+
* Use {@link UriValidator#validateQuery(String)} for strict query validation before updating this instance.
94+
*
9295
* @param queryString encoded query string to update this instance
9396
*/
9497
void fromQueryString(String queryString);

common/uri/src/test/java/io/helidon/common/uri/UriEncodingTest.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2024, 2026 Oracle and/or its affiliates.
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.
@@ -20,6 +20,7 @@
2020
import static io.helidon.common.uri.UriEncoding.decodeUri;
2121
import static org.hamcrest.MatcherAssert.assertThat;
2222
import static org.hamcrest.CoreMatchers.is;
23+
import static org.junit.jupiter.api.Assertions.assertAll;
2324

2425
class UriEncodingTest {
2526

@@ -35,4 +36,26 @@ void testSpaceDecoding() {
3536
void testIPv6Literal() {
3637
assertThat(decodeUri("http://[fe80::1%lo0]:8080"), is("http://[fe80::1%lo0]:8080"));
3738
}
39+
40+
@Test
41+
void malformedHexEscapesRemainLiteral() {
42+
assertAll(
43+
() -> assertThat(decodeUri("%2G"), is("%2G")),
44+
() -> assertThat(decodeUri("%G2"), is("%G2")),
45+
() -> assertThat(decodeUri("%+1"), is("%+1")),
46+
() -> assertThat(decodeUri("%G%41"), is("%GA")),
47+
() -> assertThat(decodeUri("%4%41"), is("%4A")),
48+
() -> assertThat(decodeUri("%%41"), is("%A")),
49+
() -> assertThat(decodeUri("%41%2G%42"), is("A%2GB")),
50+
() -> assertThat(decodeUri("prefix%G2suffix"), is("prefix%G2suffix")));
51+
}
52+
53+
@Test
54+
void incompletePercentEscapesRemainLiteral() {
55+
assertAll(
56+
() -> assertThat(decodeUri("%"), is("%")),
57+
() -> assertThat(decodeUri("%4"), is("%4")),
58+
() -> assertThat(decodeUri("%41%"), is("A%")),
59+
() -> assertThat(decodeUri("prefix%4suffix"), is("prefix%4suffix")));
60+
}
3861
}

common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2026 Oracle and/or its affiliates.
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.
@@ -27,6 +27,7 @@
2727
import static org.hamcrest.CoreMatchers.hasItems;
2828
import static org.hamcrest.MatcherAssert.assertThat;
2929
import static org.hamcrest.core.Is.is;
30+
import static org.junit.jupiter.api.Assertions.assertAll;
3031
import static org.junit.jupiter.api.Assertions.assertThrows;
3132

3233
class UriQueryTest {
@@ -63,6 +64,40 @@ void testEncodedOtherChars() {
6364
assertThat(uriQuery.get("a"), is("b&c=d"));
6465
}
6566

67+
@Test
68+
void malformedHexEscapesRemainLiteral() {
69+
assertAll(
70+
() -> assertThat(UriQuery.create("value=%2G").get("value"), is("%2G")),
71+
() -> assertThat(UriQuery.create("value=%G2").get("value"), is("%G2")),
72+
() -> assertThat(UriQuery.create("value=%+1").get("value"), is("%+1")),
73+
() -> assertThat(UriQuery.create("value=%G%41").get("value"), is("%GA")),
74+
() -> assertThat(UriQuery.create("value=%4%41").get("value"), is("%4A")),
75+
() -> assertThat(UriQuery.create("value=%%41").get("value"), is("%A")),
76+
() -> assertThat(UriQuery.create("%2G=value").get("%2G"), is("value")),
77+
() -> assertThat(UriQuery.create("%+1=value").get("%+1"), is("value")),
78+
() -> assertThat(UriQuery.create("%G%41=value").get("%GA"), is("value")),
79+
() -> assertThat(writeableQuery("value=%2G").get("value"), is("%2G")),
80+
() -> assertThat(writeableQuery("value=%G2").get("value"), is("%G2")),
81+
() -> assertThat(writeableQuery("value=%+1").get("value"), is("%+1")),
82+
() -> assertThat(writeableQuery("value=%G%41").get("value"), is("%GA")),
83+
() -> assertThat(writeableQuery("value=%4%41").get("value"), is("%4A")),
84+
() -> assertThat(writeableQuery("value=%%41").get("value"), is("%A")),
85+
() -> assertThat(writeableQuery("%G2=value").get("%G2"), is("value")),
86+
() -> assertThat(writeableQuery("%+1=value").get("%+1"), is("value")),
87+
() -> assertThat(writeableQuery("%G%41=value").get("%GA"), is("value")));
88+
}
89+
90+
@Test
91+
void incompletePercentEscapesRemainLiteral() {
92+
assertAll(
93+
() -> assertThat(UriQuery.create("value=%").get("value"), is("%")),
94+
() -> assertThat(UriQuery.create("value=%4").get("value"), is("%4")),
95+
() -> assertThat(UriQuery.create("%=value").get("%"), is("value")),
96+
() -> assertThat(writeableQuery("value=%").get("value"), is("%")),
97+
() -> assertThat(writeableQuery("value=%4").get("value"), is("%4")),
98+
() -> assertThat(writeableQuery("%4=value").get("%4"), is("value")));
99+
}
100+
66101
@Test
67102
void testEmptyQueryString() {
68103
UriQuery uriQuery = UriQuery.create("");
@@ -103,4 +138,10 @@ void testFromQueryString() {
103138
assertThat(query.get("p4"), is("a b c"));
104139
assertThat(query.getRaw("p4"), is("a%20b%20c"));
105140
}
106-
}
141+
142+
private static UriQueryWriteable writeableQuery(String queryString) {
143+
UriQueryWriteable query = UriQueryWriteable.create();
144+
query.fromQueryString(queryString);
145+
return query;
146+
}
147+
}

0 commit comments

Comments
 (0)