1 /*
2 * Copyright 2015 The Netty Project
3 *
4 * The Netty Project licenses this file to you under the Apache License,
5 * version 2.0 (the "License"); you may not use this file except in compliance
6 * with the License. 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations
14 * under the License.
15 */
16 package io.netty5.handler.codec.http;
17
18 import io.netty5.util.AsciiString;
19 import io.netty5.util.CharsetUtil;
20 import io.netty5.util.NetUtil;
21 import io.netty5.util.internal.UnstableApi;
22
23 import java.net.InetSocketAddress;
24 import java.net.URI;
25 import java.nio.charset.Charset;
26 import java.nio.charset.IllegalCharsetNameException;
27 import java.nio.charset.UnsupportedCharsetException;
28 import java.util.ArrayList;
29 import java.util.List;
30
31 import static io.netty5.util.internal.ObjectUtil.checkPositiveOrZero;
32 import static io.netty5.util.internal.StringUtil.COMMA;
33 import static java.util.Objects.requireNonNull;
34
35 /**
36 * Utility methods useful in the HTTP context.
37 */
38 public final class HttpUtil {
39
40 private static final AsciiString CHARSET_EQUALS = AsciiString.of(HttpHeaderValues.CHARSET + "=");
41 private static final AsciiString SEMICOLON = AsciiString.cached(";");
42 private static final String COMMA_STRING = String.valueOf(COMMA);
43
44 private HttpUtil() { }
45
46 /**
47 * Determine if a uri is in origin-form according to
48 * <a href="https://tools.ietf.org/html/rfc7230#section-5.3">rfc7230, 5.3</a>.
49 */
50 public static boolean isOriginForm(URI uri) {
51 return isOriginForm(uri.toString());
52 }
53
54 /**
55 * Determine if a string uri is in origin-form according to
56 * <a href="https://tools.ietf.org/html/rfc7230#section-5.3">rfc7230, 5.3</a>.
57 */
58 public static boolean isOriginForm(String uri) {
59 return uri.startsWith("/");
60 }
61
62 /**
63 * Determine if a uri is in asterisk-form according to
64 * <a href="https://tools.ietf.org/html/rfc7230#section-5.3">rfc7230, 5.3</a>.
65 */
66 public static boolean isAsteriskForm(URI uri) {
67 return isAsteriskForm(uri.toString());
68 }
69
70 /**
71 * Determine if a string uri is in asterisk-form according to
72 * <a href="https://tools.ietf.org/html/rfc7230#section-5.3">rfc7230, 5.3</a>.
73 */
74 public static boolean isAsteriskForm(String uri) {
75 return "*".equals(uri);
76 }
77
78 /**
79 * Returns {@code true} if and only if the connection can remain open and
80 * thus 'kept alive'. This methods respects the value of the.
81 *
82 * {@code "Connection"} header first and then the return value of
83 * {@link HttpVersion#isKeepAliveDefault()}.
84 */
85 public static boolean isKeepAlive(HttpMessage message) {
86 return !message.headers().containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE, true) &&
87 (message.protocolVersion().isKeepAliveDefault() ||
88 message.headers().containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE, true));
89 }
90
91 /**
92 * Sets the value of the {@code "Connection"} header depending on the
93 * protocol version of the specified message. This getMethod sets or removes
94 * the {@code "Connection"} header depending on what the default keep alive
95 * mode of the message's protocol version is, as specified by
96 * {@link HttpVersion#isKeepAliveDefault()}.
97 * <ul>
98 * <li>If the connection is kept alive by default:
99 * <ul>
100 * <li>set to {@code "close"} if {@code keepAlive} is {@code false}.</li>
101 * <li>remove otherwise.</li>
102 * </ul></li>
103 * <li>If the connection is closed by default:
104 * <ul>
105 * <li>set to {@code "keep-alive"} if {@code keepAlive} is {@code true}.</li>
106 * <li>remove otherwise.</li>
107 * </ul></li>
108 * </ul>
109 * @see #setKeepAlive(HttpHeaders, HttpVersion, boolean)
110 */
111 public static void setKeepAlive(HttpMessage message, boolean keepAlive) {
112 setKeepAlive(message.headers(), message.protocolVersion(), keepAlive);
113 }
114
115 /**
116 * Sets the value of the {@code "Connection"} header depending on the
117 * protocol version of the specified message. This getMethod sets or removes
118 * the {@code "Connection"} header depending on what the default keep alive
119 * mode of the message's protocol version is, as specified by
120 * {@link HttpVersion#isKeepAliveDefault()}.
121 * <ul>
122 * <li>If the connection is kept alive by default:
123 * <ul>
124 * <li>set to {@code "close"} if {@code keepAlive} is {@code false}.</li>
125 * <li>remove otherwise.</li>
126 * </ul></li>
127 * <li>If the connection is closed by default:
128 * <ul>
129 * <li>set to {@code "keep-alive"} if {@code keepAlive} is {@code true}.</li>
130 * <li>remove otherwise.</li>
131 * </ul></li>
132 * </ul>
133 */
134 public static void setKeepAlive(HttpHeaders h, HttpVersion httpVersion, boolean keepAlive) {
135 if (httpVersion.isKeepAliveDefault()) {
136 if (keepAlive) {
137 h.remove(HttpHeaderNames.CONNECTION);
138 } else {
139 h.set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
140 }
141 } else {
142 if (keepAlive) {
143 h.set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
144 } else {
145 h.remove(HttpHeaderNames.CONNECTION);
146 }
147 }
148 }
149
150 /**
151 * Returns the length of the content. Please note that this value is
152 * not retrieved from {@link HttpContent#payload()} but from the
153 * {@code "Content-Length"} header, and thus they are independent from each
154 * other.
155 *
156 * @return the content length
157 *
158 * @throws NumberFormatException
159 * if the message does not have the {@code "Content-Length"} header
160 * or its value is not a number
161 */
162 public static long getContentLength(HttpMessage message) {
163 String value = message.headers().get(HttpHeaderNames.CONTENT_LENGTH);
164 if (value != null) {
165 return Long.parseLong(value);
166 }
167
168 throw new NumberFormatException("header not found: " + HttpHeaderNames.CONTENT_LENGTH);
169 }
170
171 /**
172 * Returns the length of the content or the specified default value if the message does not have the {@code
173 * "Content-Length" header}. Please note that this value is not retrieved from {@link HttpContent#payload()} but
174 * from the {@code "Content-Length"} header, and thus they are independent from each other.
175 *
176 * @param message the message
177 * @param defaultValue the default value
178 * @return the content length or the specified default value
179 * @throws NumberFormatException if the {@code "Content-Length"} header does not parse as a long
180 */
181 public static long getContentLength(HttpMessage message, long defaultValue) {
182 String value = message.headers().get(HttpHeaderNames.CONTENT_LENGTH);
183 if (value != null) {
184 return Long.parseLong(value);
185 }
186
187 return defaultValue;
188 }
189
190 /**
191 * Get an {@code int} representation of {@link #getContentLength(HttpMessage, long)}.
192 *
193 * @return the content length or {@code defaultValue} if this message does
194 * not have the {@code "Content-Length"} header.
195 *
196 * @throws NumberFormatException if the {@code "Content-Length"} header does not parse as an int
197 */
198 public static int getContentLength(HttpMessage message, int defaultValue) {
199 return (int) Math.min(Integer.MAX_VALUE, getContentLength(message, (long) defaultValue));
200 }
201
202 /**
203 * Sets the {@code "Content-Length"} header.
204 */
205 public static void setContentLength(HttpMessage message, long length) {
206 message.headers().set(HttpHeaderNames.CONTENT_LENGTH, length);
207 }
208
209 public static boolean isContentLengthSet(HttpMessage m) {
210 return m.headers().contains(HttpHeaderNames.CONTENT_LENGTH);
211 }
212
213 /**
214 * Returns {@code true} if and only if the specified message contains an expect header and the only expectation
215 * present is the 100-continue expectation. Note that this method returns {@code false} if the expect header is
216 * not valid for the message (e.g., the message is a response, or the version on the message is HTTP/1.0).
217 *
218 * @param message the message
219 * @return {@code true} if and only if the expectation 100-continue is present and it is the only expectation
220 * present
221 */
222 public static boolean is100ContinueExpected(HttpMessage message) {
223 return isExpectHeaderValid(message)
224 // unquoted tokens in the expect header are case-insensitive, thus 100-continue is case insensitive
225 && message.headers().contains(HttpHeaderNames.EXPECT, HttpHeaderValues.CONTINUE, true);
226 }
227
228 /**
229 * Returns {@code true} if the specified message contains an expect header specifying an expectation that is not
230 * supported. Note that this method returns {@code false} if the expect header is not valid for the message
231 * (e.g., the message is a response, or the version on the message is HTTP/1.0).
232 *
233 * @param message the message
234 * @return {@code true} if and only if an expectation is present that is not supported
235 */
236 static boolean isUnsupportedExpectation(HttpMessage message) {
237 if (!isExpectHeaderValid(message)) {
238 return false;
239 }
240
241 final String expectValue = message.headers().get(HttpHeaderNames.EXPECT);
242 return expectValue != null && !HttpHeaderValues.CONTINUE.toString().equalsIgnoreCase(expectValue);
243 }
244
245 private static boolean isExpectHeaderValid(final HttpMessage message) {
246 /*
247 * Expect: 100-continue is for requests only and it works only on HTTP/1.1 or later. Note further that RFC 7231
248 * section 5.1.1 says "A server that receives a 100-continue expectation in an HTTP/1.0 request MUST ignore
249 * that expectation."
250 */
251 return message instanceof HttpRequest &&
252 message.protocolVersion().compareTo(HttpVersion.HTTP_1_1) >= 0;
253 }
254
255 /**
256 * Sets or removes the {@code "Expect: 100-continue"} header to / from the
257 * specified message. If {@code expected} is {@code true},
258 * the {@code "Expect: 100-continue"} header is set and all other previous
259 * {@code "Expect"} headers are removed. Otherwise, all {@code "Expect"}
260 * headers are removed completely.
261 */
262 public static void set100ContinueExpected(HttpMessage message, boolean expected) {
263 if (expected) {
264 message.headers().set(HttpHeaderNames.EXPECT, HttpHeaderValues.CONTINUE);
265 } else {
266 message.headers().remove(HttpHeaderNames.EXPECT);
267 }
268 }
269
270 /**
271 * Checks to see if the transfer encoding in a specified {@link HttpMessage} is chunked
272 *
273 * @param message The message to check
274 * @return True if transfer encoding is chunked, otherwise false
275 */
276 public static boolean isTransferEncodingChunked(HttpMessage message) {
277 return message.headers().containsValue(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED, true);
278 }
279
280 /**
281 * Set the {@link HttpHeaderNames#TRANSFER_ENCODING} to either include {@link HttpHeaderValues#CHUNKED} if
282 * {@code chunked} is {@code true}, or remove {@link HttpHeaderValues#CHUNKED} if {@code chunked} is {@code false}.
283 *
284 * @param m The message which contains the headers to modify.
285 * @param chunked if {@code true} then include {@link HttpHeaderValues#CHUNKED} in the headers. otherwise remove
286 * {@link HttpHeaderValues#CHUNKED} from the headers.
287 */
288 public static void setTransferEncodingChunked(HttpMessage m, boolean chunked) {
289 if (chunked) {
290 m.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
291 m.headers().remove(HttpHeaderNames.CONTENT_LENGTH);
292 } else {
293 List<String> encodings = m.headers().getAll(HttpHeaderNames.TRANSFER_ENCODING);
294 if (encodings.isEmpty()) {
295 return;
296 }
297 List<CharSequence> values = new ArrayList<>(encodings);
298 values.removeIf(HttpHeaderValues.CHUNKED::contentEqualsIgnoreCase);
299 if (values.isEmpty()) {
300 m.headers().remove(HttpHeaderNames.TRANSFER_ENCODING);
301 } else {
302 m.headers().set(HttpHeaderNames.TRANSFER_ENCODING, values);
303 }
304 }
305 }
306
307 /**
308 * Fetch charset from message's Content-Type header.
309 *
310 * @param message entity to fetch Content-Type header from
311 * @return the charset from message's Content-Type header or {@link CharsetUtil#ISO_8859_1}
312 * if charset is not presented or unparsable
313 */
314 public static Charset getCharset(HttpMessage message) {
315 return getCharset(message, CharsetUtil.ISO_8859_1);
316 }
317
318 /**
319 * Fetch charset from Content-Type header value.
320 *
321 * @param contentTypeValue Content-Type header value to parse
322 * @return the charset from message's Content-Type header or {@link CharsetUtil#ISO_8859_1}
323 * if charset is not presented or unparsable
324 */
325 public static Charset getCharset(CharSequence contentTypeValue) {
326 if (contentTypeValue != null) {
327 return getCharset(contentTypeValue, CharsetUtil.ISO_8859_1);
328 } else {
329 return CharsetUtil.ISO_8859_1;
330 }
331 }
332
333 /**
334 * Fetch charset from message's Content-Type header.
335 *
336 * @param message entity to fetch Content-Type header from
337 * @param defaultCharset result to use in case of empty, incorrect or doesn't contain required part header value
338 * @return the charset from message's Content-Type header or {@code defaultCharset}
339 * if charset is not presented or unparsable
340 */
341 public static Charset getCharset(HttpMessage message, Charset defaultCharset) {
342 CharSequence contentTypeValue = message.headers().get(HttpHeaderNames.CONTENT_TYPE);
343 if (contentTypeValue != null) {
344 return getCharset(contentTypeValue, defaultCharset);
345 } else {
346 return defaultCharset;
347 }
348 }
349
350 /**
351 * Fetch charset from Content-Type header value.
352 *
353 * @param contentTypeValue Content-Type header value to parse
354 * @param defaultCharset result to use in case of empty, incorrect or doesn't contain required part header value
355 * @return the charset from message's Content-Type header or {@code defaultCharset}
356 * if charset is not presented or unparsable
357 */
358 public static Charset getCharset(CharSequence contentTypeValue, Charset defaultCharset) {
359 if (contentTypeValue != null) {
360 CharSequence charsetRaw = getCharsetAsSequence(contentTypeValue);
361 if (charsetRaw != null) {
362 if (charsetRaw.length() > 2) { // at least contains 2 quotes(")
363 if (charsetRaw.charAt(0) == '"' && charsetRaw.charAt(charsetRaw.length() - 1) == '"') {
364 charsetRaw = charsetRaw.subSequence(1, charsetRaw.length() - 1);
365 }
366 }
367 try {
368 return Charset.forName(charsetRaw.toString());
369 } catch (UnsupportedCharsetException | IllegalCharsetNameException ignored) {
370 // just return the default charset
371 return defaultCharset;
372 }
373 } else {
374 return defaultCharset;
375 }
376 } else {
377 return defaultCharset;
378 }
379 }
380
381 /**
382 * Fetch charset from message's Content-Type header as a char sequence.
383 *
384 * A lot of sites/possibly clients have charset="CHARSET", for example charset="utf-8". Or "utf8" instead of "utf-8"
385 * This is not according to standard, but this method provide an ability to catch desired mistakes manually in code
386 *
387 * @param message entity to fetch Content-Type header from
388 * @return the {@code CharSequence} with charset from message's Content-Type header
389 * or {@code null} if charset is not presented
390 * @deprecated use {@link #getCharsetAsSequence(HttpMessage)}
391 */
392 @Deprecated
393 public static CharSequence getCharsetAsString(HttpMessage message) {
394 return getCharsetAsSequence(message);
395 }
396
397 /**
398 * Fetch charset from message's Content-Type header as a char sequence.
399 *
400 * A lot of sites/possibly clients have charset="CHARSET", for example charset="utf-8". Or "utf8" instead of "utf-8"
401 * This is not according to standard, but this method provide an ability to catch desired mistakes manually in code
402 *
403 * @return the {@code CharSequence} with charset from message's Content-Type header
404 * or {@code null} if charset is not presented
405 */
406 public static CharSequence getCharsetAsSequence(HttpMessage message) {
407 CharSequence contentTypeValue = message.headers().get(HttpHeaderNames.CONTENT_TYPE);
408 if (contentTypeValue != null) {
409 return getCharsetAsSequence(contentTypeValue);
410 } else {
411 return null;
412 }
413 }
414
415 /**
416 * Fetch charset from Content-Type header value as a char sequence.
417 *
418 * A lot of sites/possibly clients have charset="CHARSET", for example charset="utf-8". Or "utf8" instead of "utf-8"
419 * This is not according to standard, but this method provide an ability to catch desired mistakes manually in code
420 *
421 * @param contentTypeValue Content-Type header value to parse
422 * @return the {@code CharSequence} with charset from message's Content-Type header
423 * or {@code null} if charset is not presented
424 * @throws NullPointerException in case if {@code contentTypeValue == null}
425 */
426 public static CharSequence getCharsetAsSequence(CharSequence contentTypeValue) {
427 requireNonNull(contentTypeValue, "contentTypeValue");
428
429 int indexOfCharset = AsciiString.indexOfIgnoreCaseAscii(contentTypeValue, CHARSET_EQUALS, 0);
430 if (indexOfCharset == AsciiString.INDEX_NOT_FOUND) {
431 return null;
432 }
433
434 int indexOfEncoding = indexOfCharset + CHARSET_EQUALS.length();
435 if (indexOfEncoding < contentTypeValue.length()) {
436 CharSequence charsetCandidate = contentTypeValue.subSequence(indexOfEncoding, contentTypeValue.length());
437 int indexOfSemicolon = AsciiString.indexOfIgnoreCaseAscii(charsetCandidate, SEMICOLON, 0);
438 if (indexOfSemicolon == AsciiString.INDEX_NOT_FOUND) {
439 return charsetCandidate;
440 }
441
442 return charsetCandidate.subSequence(0, indexOfSemicolon);
443 }
444
445 return null;
446 }
447
448 /**
449 * Fetch MIME type part from message's Content-Type header as a char sequence.
450 *
451 * @param message entity to fetch Content-Type header from
452 * @return the MIME type as a {@code CharSequence} from message's Content-Type header
453 * or {@code null} if content-type header or MIME type part of this header are not presented
454 * <p/>
455 * "content-type: text/html; charset=utf-8" - "text/html" will be returned <br/>
456 * "content-type: text/html" - "text/html" will be returned <br/>
457 * "content-type: " or no header - {@code null} we be returned
458 */
459 public static CharSequence getMimeType(HttpMessage message) {
460 CharSequence contentTypeValue = message.headers().get(HttpHeaderNames.CONTENT_TYPE);
461 if (contentTypeValue != null) {
462 return getMimeType(contentTypeValue);
463 } else {
464 return null;
465 }
466 }
467
468 /**
469 * Fetch MIME type part from Content-Type header value as a char sequence.
470 *
471 * @param contentTypeValue Content-Type header value to parse
472 * @return the MIME type as a {@code CharSequence} from message's Content-Type header
473 * or {@code null} if content-type header or MIME type part of this header are not presented
474 * <p/>
475 * "content-type: text/html; charset=utf-8" - "text/html" will be returned <br/>
476 * "content-type: text/html" - "text/html" will be returned <br/>
477 * "content-type: empty header - {@code null} we be returned
478 * @throws NullPointerException in case if {@code contentTypeValue == null}
479 */
480 public static CharSequence getMimeType(CharSequence contentTypeValue) {
481 requireNonNull(contentTypeValue, "contentTypeValue");
482
483 int indexOfSemicolon = AsciiString.indexOfIgnoreCaseAscii(contentTypeValue, SEMICOLON, 0);
484 if (indexOfSemicolon != AsciiString.INDEX_NOT_FOUND) {
485 return contentTypeValue.subSequence(0, indexOfSemicolon);
486 } else {
487 return contentTypeValue.length() > 0 ? contentTypeValue : null;
488 }
489 }
490
491 /**
492 * Formats the host string of an address so it can be used for computing an HTTP component
493 * such as a URL or a Host header
494 *
495 * @param addr the address
496 * @return the formatted String
497 */
498 public static String formatHostnameForHttp(InetSocketAddress addr) {
499 String hostString = NetUtil.getHostname(addr);
500 if (NetUtil.isValidIpV6Address(hostString)) {
501 if (!addr.isUnresolved()) {
502 hostString = NetUtil.toAddressString(addr.getAddress());
503 }
504 return '[' + hostString + ']';
505 }
506 return hostString;
507 }
508
509 /**
510 * Validates, and optionally extracts the content length from headers. This method is not intended for
511 * general use, but is here to be shared between HTTP/1 and HTTP/2 parsing.
512 *
513 * @param contentLengthFields the content-length header fields.
514 * @param isHttp10OrEarlier {@code true} if we are handling HTTP/1.0 or earlier
515 * @param allowDuplicateContentLengths {@code true} if multiple, identical-value content lengths should be allowed.
516 * @return the normalized content length from the headers or {@code -1} if the fields were empty.
517 * @throws IllegalArgumentException if the content-length fields are not valid
518 */
519 @UnstableApi
520 public static long normalizeAndGetContentLength(
521 List<? extends CharSequence> contentLengthFields, boolean isHttp10OrEarlier,
522 boolean allowDuplicateContentLengths) {
523 if (contentLengthFields.isEmpty()) {
524 return -1;
525 }
526
527 // Guard against multiple Content-Length headers as stated in
528 // https://tools.ietf.org/html/rfc7230#section-3.3.2:
529 //
530 // If a message is received that has multiple Content-Length header
531 // fields with field-values consisting of the same decimal value, or a
532 // single Content-Length header field with a field value containing a
533 // list of identical decimal values (e.g., "Content-Length: 42, 42"),
534 // indicating that duplicate Content-Length header fields have been
535 // generated or combined by an upstream message processor, then the
536 // recipient MUST either reject the message as invalid or replace the
537 // duplicated field-values with a single valid Content-Length field
538 // containing that decimal value prior to determining the message body
539 // length or forwarding the message.
540 String firstField = contentLengthFields.get(0).toString();
541 boolean multipleContentLengths =
542 contentLengthFields.size() > 1 || firstField.indexOf(COMMA) >= 0;
543
544 if (multipleContentLengths && !isHttp10OrEarlier) {
545 if (allowDuplicateContentLengths) {
546 // Find and enforce that all Content-Length values are the same
547 String firstValue = null;
548 for (CharSequence field : contentLengthFields) {
549 String[] tokens = field.toString().split(COMMA_STRING, -1);
550 for (String token : tokens) {
551 String trimmed = token.trim();
552 if (firstValue == null) {
553 firstValue = trimmed;
554 } else if (!trimmed.equals(firstValue)) {
555 throw new IllegalArgumentException(
556 "Multiple Content-Length values found: " + contentLengthFields);
557 }
558 }
559 }
560 // Replace the duplicated field-values with a single valid Content-Length field
561 firstField = firstValue;
562 } else {
563 // Reject the message as invalid
564 throw new IllegalArgumentException(
565 "Multiple Content-Length values found: " + contentLengthFields);
566 }
567 }
568 // Ensure we not allow sign as part of the content-length:
569 // See https://github.com/squid-cache/squid/security/advisories/GHSA-qf3v-rc95-96j5
570 if (firstField.isEmpty() || !Character.isDigit(firstField.charAt(0))) {
571 // Reject the message as invalid
572 throw new IllegalArgumentException(
573 "Content-Length value is not a number: " + firstField);
574 }
575 try {
576 final long value = Long.parseLong(firstField);
577 return checkPositiveOrZero(value, "Content-Length value");
578 } catch (NumberFormatException e) {
579 // Reject the message as invalid
580 throw new IllegalArgumentException(
581 "Content-Length value is not a number: " + firstField, e);
582 }
583 }
584 }