Skip to content

Commit 6f73ec8

Browse files
committed
HttpServerRequest supports query parameters.
1 parent 0cfc731 commit 6f73ec8

File tree

7 files changed

+563
-0
lines changed

7 files changed

+563
-0
lines changed

reactor-netty-http/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ task japicmp(type: JapicmpTask) {
241241

242242
compatibilityChangeExcludes = [ "METHOD_NEW_DEFAULT" ]
243243
methodExcludes = [
244+
'reactor.netty.http.server.HttpServerRequest#queryParams()'
244245
]
245246
}
246247

reactor-netty-http/src/main/java/reactor/netty/http/HttpOperations.java

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import java.net.URI;
1919
import java.nio.file.Path;
20+
import java.util.List;
21+
import java.util.Map;
2022
import java.util.Objects;
2123
import java.util.concurrent.Callable;
2224
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
@@ -251,6 +253,10 @@ protected HttpMessageLogFactory httpMessageLogFactory() {
251253

252254
protected abstract void beforeMarkSentHeaders();
253255

256+
protected Map<String, List<String>> parseQueryParams(String uri) {
257+
return QueryStringDecoder.decodeParams(uri);
258+
}
259+
254260
protected abstract void afterMarkSentHeaders();
255261

256262
protected abstract boolean isContentAlwaysEmpty();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
* Copyright (c) 2012-2023 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.http;
17+
18+
import static io.netty.util.internal.StringUtil.EMPTY_STRING;
19+
import static io.netty.util.internal.StringUtil.SPACE;
20+
import static io.netty.util.internal.StringUtil.decodeHexByte;
21+
22+
import io.netty.handler.codec.http.HttpConstants;
23+
import io.netty.util.internal.PlatformDependent;
24+
25+
import java.nio.charset.Charset;
26+
import java.util.ArrayList;
27+
import java.util.Collections;
28+
import java.util.LinkedHashMap;
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.Objects;
32+
33+
/**
34+
* Provides utility methods to split an HTTP query string into key-value parameter pairs.
35+
* <pre>
36+
* {@link Map} parameters = {@link QueryStringDecoder}.decodeParams("/hello?recipient=world&x=1;y=2");
37+
* assert parameters.get("recipient").get(0).equals("world");
38+
* assert parameters.get.get("x").get(0).equals("1");
39+
* assert parameters.get.get("y").get(0).equals("2");
40+
* </pre>
41+
*
42+
*
43+
* <h3>HashDOS vulnerability fix</h3>
44+
*
45+
* As a workaround to the <a href="https://netty.io/s/hashdos">HashDOS</a> vulnerability, the decoder
46+
* limits the maximum number of decoded key-value parameter pairs, up to {@literal 1024} by
47+
* default. you can configure it when you construct the decoder by passing an additional
48+
* integer parameter.
49+
*
50+
*/
51+
public class QueryStringDecoder {
52+
53+
private static final int DEFAULT_MAX_PARAMS = 1024;
54+
55+
public static Map<String, List<String>> decodeParams(final String uri) {
56+
57+
return decodeParams(uri, HttpConstants.DEFAULT_CHARSET,
58+
DEFAULT_MAX_PARAMS, true);
59+
}
60+
61+
public static Map<String, List<String>> decodeParams(final String uri, final boolean semiColonIsNormalChar) {
62+
63+
return decodeParams(uri, HttpConstants.DEFAULT_CHARSET,
64+
DEFAULT_MAX_PARAMS, semiColonIsNormalChar);
65+
}
66+
67+
public static Map<String, List<String>> decodeParams(final String uri, final Charset charset, final int maxParams, final boolean semicolonIsNormalChar) {
68+
Objects.requireNonNull(uri, "uri");
69+
Objects.requireNonNull(charset, "charset");
70+
if (maxParams < 1) {
71+
throw new IllegalArgumentException("maxParams must be positive");
72+
}
73+
74+
int from = findPathEndIndex(uri);
75+
return decodeParams(uri, from, charset,
76+
maxParams, semicolonIsNormalChar);
77+
}
78+
79+
private static Map<String, List<String>> decodeParams(String s, int from, Charset charset, int paramsLimit,
80+
boolean semicolonIsNormalChar) {
81+
int len = s.length();
82+
if (from >= len) {
83+
return Collections.emptyMap();
84+
}
85+
if (s.charAt(from) == '?') {
86+
from++;
87+
}
88+
Map<String, List<String>> params = new LinkedHashMap<String, List<String>>();
89+
int nameStart = from;
90+
int valueStart = -1;
91+
int i;
92+
loop:
93+
for (i = from; i < len; i++) {
94+
switch (s.charAt(i)) {
95+
case '=':
96+
if (nameStart == i) {
97+
nameStart = i + 1;
98+
}
99+
else if (valueStart < nameStart) {
100+
valueStart = i + 1;
101+
}
102+
break;
103+
case ';':
104+
if (semicolonIsNormalChar) {
105+
continue;
106+
}
107+
// fall-through
108+
case '&':
109+
if (addParam(s, nameStart, valueStart, i, params, charset)) {
110+
paramsLimit--;
111+
if (paramsLimit == 0) {
112+
return params;
113+
}
114+
}
115+
nameStart = i + 1;
116+
break;
117+
case '#':
118+
break loop;
119+
default:
120+
// continue
121+
}
122+
}
123+
addParam(s, nameStart, valueStart, i, params, charset);
124+
return params;
125+
}
126+
127+
private static boolean addParam(String s, int nameStart, int valueStart, int valueEnd,
128+
Map<String, List<String>> params, Charset charset) {
129+
if (nameStart >= valueEnd) {
130+
return false;
131+
}
132+
if (valueStart <= nameStart) {
133+
valueStart = valueEnd + 1;
134+
}
135+
String name = decodeComponent(s, nameStart, valueStart - 1, charset, false);
136+
String value = decodeComponent(s, valueStart, valueEnd, charset, false);
137+
List<String> values = params.get(name);
138+
if (values == null) {
139+
values = new ArrayList<String>(1); // Often there's only 1 value.
140+
params.put(name, values);
141+
}
142+
values.add(value);
143+
return true;
144+
}
145+
146+
private static String decodeComponent(String s, int from, int toExcluded, Charset charset, boolean isPath) {
147+
int len = toExcluded - from;
148+
if (len <= 0) {
149+
return EMPTY_STRING;
150+
}
151+
int firstEscaped = -1;
152+
for (int i = from; i < toExcluded; i++) {
153+
char c = s.charAt(i);
154+
if (c == '%' || c == '+' && !isPath) {
155+
firstEscaped = i;
156+
break;
157+
}
158+
}
159+
if (firstEscaped == -1) {
160+
return s.substring(from, toExcluded);
161+
}
162+
163+
// Each encoded byte takes 3 characters (e.g. "%20")
164+
int decodedCapacity = (toExcluded - firstEscaped) / 3;
165+
byte[] buf = PlatformDependent.allocateUninitializedArray(decodedCapacity);
166+
int bufIdx;
167+
168+
StringBuilder strBuf = new StringBuilder(len);
169+
strBuf.append(s, from, firstEscaped);
170+
171+
for (int i = firstEscaped; i < toExcluded; i++) {
172+
char c = s.charAt(i);
173+
if (c != '%') {
174+
strBuf.append(c != '+' || isPath ? c : SPACE);
175+
continue;
176+
}
177+
178+
bufIdx = 0;
179+
do {
180+
if (i + 3 > toExcluded) {
181+
throw new IllegalArgumentException("unterminated escape sequence at index " + i + " of: " + s);
182+
}
183+
buf[bufIdx++] = decodeHexByte(s, i + 1);
184+
i += 3;
185+
} while (i < toExcluded && s.charAt(i) == '%');
186+
i--;
187+
188+
strBuf.append(new String(buf, 0, bufIdx, charset));
189+
}
190+
return strBuf.toString();
191+
}
192+
193+
private static int findPathEndIndex(String uri) {
194+
int len = uri.length();
195+
for (int i = 0; i < len; i++) {
196+
char c = uri.charAt(i);
197+
if (c == '?' || c == '#') {
198+
return i;
199+
}
200+
}
201+
return len;
202+
}
203+
}

reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java

+10
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.nio.file.Files;
2222
import java.nio.file.Path;
2323
import java.time.ZonedDateTime;
24+
import java.util.Collections;
2425
import java.util.HashSet;
2526
import java.util.List;
2627
import java.util.Locale;
@@ -124,6 +125,7 @@ class HttpServerOperations extends HttpOperations<HttpServerRequest, HttpServerR
124125
final String scheme;
125126
final ZonedDateTime timestamp;
126127

128+
Map<String, List<String>> queryParamsMap;
127129
BiPredicate<HttpServerRequest, HttpServerResponse> compressionPredicate;
128130
Function<? super String, Map<String, String>> paramsResolver;
129131
String path;
@@ -466,6 +468,14 @@ public HttpHeaders requestHeaders() {
466468
throw new IllegalStateException("request not parsed");
467469
}
468470

471+
@Override
472+
public Map<String, List<String>> queryParams() {
473+
if (queryParamsMap == null) {
474+
queryParamsMap = Collections.unmodifiableMap(parseQueryParams(this.nettyRequest.uri()));
475+
}
476+
return queryParamsMap;
477+
}
478+
469479
@Override
470480
public String scheme() {
471481
if (connectionInfo != null) {

reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java

+9
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.net.InetSocketAddress;
1919
import java.time.ZonedDateTime;
20+
import java.util.List;
2021
import java.util.Map;
2122
import java.util.function.Consumer;
2223
import java.util.function.Function;
@@ -143,6 +144,14 @@ default Flux<HttpContent> receiveContent() {
143144
*/
144145
HttpHeaders requestHeaders();
145146

147+
/**
148+
* return parsed and decoded query parameter name value pairs
149+
*
150+
* @return query parameters {@link Map}
151+
* @since 1.1.6
152+
*/
153+
Map<String, List<String>> queryParams();
154+
146155
/**
147156
* Returns the inbound protocol and version.
148157
*

0 commit comments

Comments
 (0)