diff --git a/reactor-netty-http/build.gradle b/reactor-netty-http/build.gradle index 02b717a633..8dd3ef5802 100644 --- a/reactor-netty-http/build.gradle +++ b/reactor-netty-http/build.gradle @@ -250,6 +250,7 @@ task japicmp(type: JapicmpTask) { compatibilityChangeExcludes = [ "METHOD_NEW_DEFAULT" ] methodExcludes = [ + 'reactor.netty.http.server.HttpServerRequest#queryParams()' ] } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/HttpOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/HttpOperations.java index 8a9f059665..2e8b0912dc 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/HttpOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/HttpOperations.java @@ -17,6 +17,8 @@ import java.net.URI; import java.nio.file.Path; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; @@ -228,6 +230,10 @@ protected HttpMessageLogFactory httpMessageLogFactory() { protected abstract void beforeMarkSentHeaders(); + protected Map> parseQueryParams(String uri) { + return QueryStringDecoder.decodeParams(uri); + } + protected abstract void afterMarkSentHeaders(); protected abstract boolean isContentAlwaysEmpty(); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/QueryStringDecoder.java b/reactor-netty-http/src/main/java/reactor/netty/http/QueryStringDecoder.java new file mode 100644 index 0000000000..d635c91b57 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/QueryStringDecoder.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2012-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package reactor.netty.http; + +import static io.netty.util.internal.StringUtil.EMPTY_STRING; +import static io.netty.util.internal.StringUtil.SPACE; +import static io.netty.util.internal.StringUtil.decodeHexByte; + +import io.netty.handler.codec.http.HttpConstants; +import io.netty.util.internal.PlatformDependent; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Provides utility methods to split an HTTP query string into key-value parameter pairs. + *
+ * {@link Map} parameters = {@link QueryStringDecoder}.decodeParams("/hello?recipient=world&x=1;y=2");
+ * assert parameters.get("recipient").get(0).equals("world");
+ * assert parameters.get.get("x").get(0).equals("1");
+ * assert parameters.get.get("y").get(0).equals("2");
+ * 
+ * + * + *

HashDOS vulnerability fix

+ * + * As a workaround to the HashDOS vulnerability, the decoder + * limits the maximum number of decoded key-value parameter pairs, up to {@literal 1024} by + * default. you can configure it when you construct the decoder by passing an additional + * integer parameter. + * + */ +public class QueryStringDecoder { + + private static final int DEFAULT_MAX_PARAMS = 1024; + + public static Map> decodeParams(final String uri) { + + return decodeParams(uri, HttpConstants.DEFAULT_CHARSET, + DEFAULT_MAX_PARAMS, true); + } + + public static Map> decodeParams(final String uri, final boolean semiColonIsNormalChar) { + + return decodeParams(uri, HttpConstants.DEFAULT_CHARSET, + DEFAULT_MAX_PARAMS, semiColonIsNormalChar); + } + + public static Map> decodeParams(final String uri, final Charset charset, final int maxParams, final boolean semicolonIsNormalChar) { + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(charset, "charset"); + if (maxParams < 1) { + throw new IllegalArgumentException("maxParams must be positive"); + } + + int from = findPathEndIndex(uri); + return decodeParams(uri, from, charset, + maxParams, semicolonIsNormalChar); + } + + private static Map> decodeParams(String s, int from, Charset charset, int paramsLimit, + boolean semicolonIsNormalChar) { + int len = s.length(); + if (from >= len) { + return Collections.emptyMap(); + } + if (s.charAt(from) == '?') { + from++; + } + Map> params = new LinkedHashMap>(); + int nameStart = from; + int valueStart = -1; + int i; + loop: + for (i = from; i < len; i++) { + switch (s.charAt(i)) { + case '=': + if (nameStart == i) { + nameStart = i + 1; + } + else if (valueStart < nameStart) { + valueStart = i + 1; + } + break; + case ';': + if (semicolonIsNormalChar) { + continue; + } + // fall-through + case '&': + if (addParam(s, nameStart, valueStart, i, params, charset)) { + paramsLimit--; + if (paramsLimit == 0) { + return params; + } + } + nameStart = i + 1; + break; + case '#': + break loop; + default: + // continue + } + } + addParam(s, nameStart, valueStart, i, params, charset); + return params; + } + + private static boolean addParam(String s, int nameStart, int valueStart, int valueEnd, + Map> params, Charset charset) { + if (nameStart >= valueEnd) { + return false; + } + if (valueStart <= nameStart) { + valueStart = valueEnd + 1; + } + String name = decodeComponent(s, nameStart, valueStart - 1, charset, false); + String value = decodeComponent(s, valueStart, valueEnd, charset, false); + List values = params.get(name); + if (values == null) { + values = new ArrayList(1); // Often there's only 1 value. + params.put(name, values); + } + values.add(value); + return true; + } + + private static String decodeComponent(String s, int from, int toExcluded, Charset charset, boolean isPath) { + int len = toExcluded - from; + if (len <= 0) { + return EMPTY_STRING; + } + int firstEscaped = -1; + for (int i = from; i < toExcluded; i++) { + char c = s.charAt(i); + if (c == '%' || c == '+' && !isPath) { + firstEscaped = i; + break; + } + } + if (firstEscaped == -1) { + return s.substring(from, toExcluded); + } + + // Each encoded byte takes 3 characters (e.g. "%20") + int decodedCapacity = (toExcluded - firstEscaped) / 3; + byte[] buf = PlatformDependent.allocateUninitializedArray(decodedCapacity); + int bufIdx; + + StringBuilder strBuf = new StringBuilder(len); + strBuf.append(s, from, firstEscaped); + + for (int i = firstEscaped; i < toExcluded; i++) { + char c = s.charAt(i); + if (c != '%') { + strBuf.append(c != '+' || isPath ? c : SPACE); + continue; + } + + bufIdx = 0; + do { + if (i + 3 > toExcluded) { + throw new IllegalArgumentException("unterminated escape sequence at index " + i + " of: " + s); + } + buf[bufIdx++] = decodeHexByte(s, i + 1); + i += 3; + } while (i < toExcluded && s.charAt(i) == '%'); + i--; + + strBuf.append(new String(buf, 0, bufIdx, charset)); + } + return strBuf.toString(); + } + + private static int findPathEndIndex(String uri) { + int len = uri.length(); + for (int i = 0; i < len; i++) { + char c = uri.charAt(i); + if (c == '?' || c == '#') { + return i; + } + } + return len; + } +} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java index c92191d301..c26bd2a844 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java @@ -22,6 +22,7 @@ import java.nio.file.Path; import java.time.Duration; import java.time.ZonedDateTime; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -128,6 +129,7 @@ class HttpServerOperations extends HttpOperations> queryParamsMap; BiPredicate compressionPredicate; Function> paramsResolver; String path; @@ -479,6 +481,14 @@ public HttpHeaders requestHeaders() { throw new IllegalStateException("request not parsed"); } + @Override + public Map> queryParams() { + if (queryParamsMap == null) { + queryParamsMap = Collections.unmodifiableMap(parseQueryParams(this.nettyRequest.uri())); + } + return queryParamsMap; + } + @Override public String scheme() { return this.connectionInfo.getScheme(); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java index 61e2026c4d..5cf28fddb3 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java @@ -17,6 +17,7 @@ import java.net.InetSocketAddress; import java.time.ZonedDateTime; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -143,6 +144,14 @@ default Flux receiveContent() { */ HttpHeaders requestHeaders(); + /** + * return parsed and decoded query parameter name value pairs + * + * @return query parameters {@link Map} + * @since 1.1.6 + */ + Map> queryParams(); + /** * Returns the inbound protocol and version. * diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/QueryStringDecoderTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/QueryStringDecoderTests.java new file mode 100644 index 0000000000..24a83d2988 --- /dev/null +++ b/reactor-netty-http/src/test/java/reactor/netty/http/QueryStringDecoderTests.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2023 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package reactor.netty.http; + + + + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +public class QueryStringDecoderTests { + + + @Test + public void testBasicUrls() { + Map> parameters = QueryStringDecoder.decodeParams("http://localhost/path"); + Assertions.assertThat(parameters.size()).isEqualTo(0); + } + + @Test + public void testBasic() { + Map> parameters = QueryStringDecoder.decodeParams("/foo"); + Assertions.assertThat(parameters.size()).isEqualTo(0); + + parameters = QueryStringDecoder.decodeParams("/foo%20bar"); + Assertions.assertThat(parameters.size()).isEqualTo(0); + + parameters = QueryStringDecoder.decodeParams("/foo?a=b=c"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("b=c"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1&a=2"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo%20bar?a=1&a=2"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=&a=2"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo(""); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1&a="); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo(""); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1&a=&a="); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(3); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo(""); + Assertions.assertThat(parameters.get("a").get(2)).isEqualTo(""); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1=&a==2"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1="); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("=2"); + + parameters = QueryStringDecoder.decodeParams("/foo?abc=1%2023&abc=124%20"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("abc").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("abc").get(0)).isEqualTo("1 23"); + Assertions.assertThat(parameters.get("abc").get(1)).isEqualTo("124 "); + + parameters = QueryStringDecoder.decodeParams("/foo?abc=%7E"); + Assertions.assertThat(parameters.get("abc").get(0)).isEqualTo("~"); + + parameters = QueryStringDecoder.decodeParams("/foo?%23abc=%7E"); + Assertions.assertThat(parameters.get("#abc").get(0)).isEqualTo("~"); + + } + + + @Test + public void testSemicolon() { + Map> parameters = QueryStringDecoder.decodeParams("/foo?a=1;2", false); + Assertions.assertThat(parameters.size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("2").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("2").get(0)).isEqualTo(""); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1;2"); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1;2"); + + parameters = QueryStringDecoder.decodeParams("/foo?abc=1;abc=2", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("abc").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("abc").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo", false); + Assertions.assertThat(parameters.size()).isEqualTo(0); + + parameters = QueryStringDecoder.decodeParams("/foo%20bar", false); + Assertions.assertThat(parameters.size()).isEqualTo(0); + + parameters = QueryStringDecoder.decodeParams("/foo?a=b=c", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("b=c"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1;a=2", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo%20bar?a=1;a=2", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=;a=2", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo(""); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1;a=", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo(""); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1;a=;a=", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(3); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo(""); + Assertions.assertThat(parameters.get("a").get(2)).isEqualTo(""); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1=;a==2", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1="); + Assertions.assertThat(parameters.get("a").get(1)).isEqualTo("=2"); + + parameters = QueryStringDecoder.decodeParams("/foo?abc=1%2023;abc=124%20", false); + Assertions.assertThat(parameters.size()).isEqualTo(1); + Assertions.assertThat(parameters.get("abc").size()).isEqualTo(2); + Assertions.assertThat(parameters.get("abc").get(0)).isEqualTo("1 23"); + Assertions.assertThat(parameters.get("abc").get(1)).isEqualTo("124 "); + + parameters = QueryStringDecoder.decodeParams("/foo?abc=%7E", false); + Assertions.assertThat(parameters.get("abc").get(0)).isEqualTo("~"); + } + + @Test + public void testFragment() { + Map> parameters = QueryStringDecoder.decodeParams("/foo?a=1&b=2#anchor"); + Assertions.assertThat(parameters.size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("b").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("b").get(0)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo?a=1;b=2#anchor", false); + Assertions.assertThat(parameters.size()).isEqualTo(2); + Assertions.assertThat(parameters.get("a").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("a").get(0)).isEqualTo("1"); + Assertions.assertThat(parameters.get("b").size()).isEqualTo(1); + Assertions.assertThat(parameters.get("b").get(0)).isEqualTo("2"); + + parameters = QueryStringDecoder.decodeParams("/foo/#anchor?a=1;b=2#anchor"); + Assertions.assertThat(parameters.size()).isEqualTo(0); + } + + @Test + public void testHashDos() { + StringBuilder buf = new StringBuilder(); + buf.append('?'); + for (int i = 0; i < 65536; i++) { + buf.append('k'); + buf.append(i); + buf.append("=v"); + buf.append(i); + buf.append('&'); + } + Assertions.assertThat(QueryStringDecoder.decodeParams(buf.toString()).size()).isEqualTo(1024); + } + + @Test + public void testNullUri() { + Assertions.assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> QueryStringDecoder.decodeParams(null)); + } + + @Test + public void testNullCharset() { + Assertions.assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> QueryStringDecoder.decodeParams("/foo", null, 1024, true)); + } + + @Test + public void testNegativeMaxNumberOfParameters() { + Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> QueryStringDecoder.decodeParams("/foo", StandardCharsets.UTF_8, 0, true)); + } + + @Test + public void testUnterminatedEncodedValue() { + Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> QueryStringDecoder.decodeParams("/foo?id=1%")); + } + + @Test + public void testUnterminatedEncodedName() { + Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> QueryStringDecoder.decodeParams("/foo?%id=1")); + } +} diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java index 4ba44b8d42..58167efe18 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java @@ -2819,6 +2819,101 @@ void testUseComparatorOrderRoutes() { .verifyComplete(); } + @Test + void testRouteQuery() { + HttpServerRoutes serverRoutes = HttpServerRoutes.newRoutes() + .get("/yes/value", + (request, response) -> { + StringBuilder sbuf = new StringBuilder(); + request.queryParams().forEach((key, list) -> { + for (String value : list) { + + sbuf.append(key); + sbuf.append('='); + sbuf.append(value); + sbuf.append('&'); + } + sbuf.deleteCharAt(sbuf.length() - 1); + }); + return response.sendString(Mono.just("/yes/value?" + sbuf)); + }).route(r -> true, (req, resp) -> { + return resp.sendString(Mono.just("/default")); + }); + + try { + disposableServer = HttpServer + .create() + .handle(serverRoutes) + .bindNow(); + + // verify HttpServerRequest has query parameters as the incoming request has query parameters + StepVerifier + .create(createClient(disposableServer.port()) + .get() + .uri("/yes/value?id=a&id=b") + .responseSingle((response, mono) -> mono.asString())) + .expectNext("/yes/value?id=a&id=b") + .verifyComplete(); + + // verify HttpServerRequest does not have query parameters if the http request does not have query + // parameters + StepVerifier + .create(createClient(disposableServer.port()) + .get() + .uri("/yes/value") + .responseSingle((response, mono) -> mono.asString())) + .expectNext("/yes/value?") + .verifyComplete(); + } + finally { + if (disposableServer != null) { + disposableServer.disposeNow(); + } + } + } + + @Test + void testRouteQueryUnterminatedEncodedQueryParameters() { + HttpServerRoutes serverRoutes = HttpServerRoutes.newRoutes() + .get("/yes/value", + (request, response) -> { + StringBuilder sbuf = new StringBuilder(); + request.queryParams().forEach((key, list) -> { + + for (String value : list) { + sbuf.append(key); + sbuf.append('='); + sbuf.append(value); + sbuf.append('&'); + } + sbuf.deleteCharAt(sbuf.length() - 1); + }); + return response.sendString(Mono.just("/yes/value?" + sbuf)); + }).route(r -> true, (req, resp) -> { + return resp.sendString(Mono.just("/default")); + }); + + try { + disposableServer = HttpServer + .create() + .handle(serverRoutes) + .bindNow(); + + // verify HttpServerRequest has query parameters as the incoming request has query parameters + StepVerifier + .create(createClient(disposableServer.port()) + .get() + .uri("/yes/value?id=a%&id=b") + .responseSingle((response, mono) -> mono.asString())) + .expectError(IllegalArgumentException.class); + } + finally { + if (disposableServer != null) { + disposableServer.disposeNow(); + } + } + } + @Test void testOverrideRouteOrder() { HttpServerRoutes serverRoutes = HttpServerRoutes.newRoutes()