From 5c4211bc79b7fd0ea8e7cc6dc665a3889dd48af3 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 7 Nov 2024 11:09:16 +0100 Subject: [PATCH] URI Validation updates (#9469) * Validation of URI Query * Moving host validation to UriValidator, as it belongs to URI concept (and is part of URI specification). The UriValidator now has methods to validate scheme, host, query, and fragment. Path validation is retained in `UriPath`. Added a new configuration option to disable validation of prologue for HTTP/1. Signed-off-by: Tomas Langer --- .../io/helidon/common/uri/UriFragment.java | 8 +- .../java/io/helidon/common/uri/UriQuery.java | 18 +- .../io/helidon/common/uri/UriQueryImpl.java | 5 + .../common/uri/UriValidationException.java | 209 ++++++ .../io/helidon/common/uri/UriValidator.java | 653 ++++++++++++++++++ .../io/helidon/common/uri/UriQueryTest.java | 1 - .../helidon/common/uri/UriValidatorTest.java | 297 ++++++++ .../java/io/helidon/http/HostValidator.java | 310 +-------- .../java/io/helidon/http/HttpPrologue.java | 4 +- .../io/helidon/http/HostValidatorTest.java | 110 +-- .../webserver/tests/BadPrologueTest.java | 125 ++++ .../webserver/http1/Http1ConfigBlueprint.java | 13 +- .../webserver/http1/Http1Connection.java | 36 +- .../http1/ValidateHostHeaderTest.java | 7 +- 14 files changed, 1433 insertions(+), 363 deletions(-) create mode 100644 common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java create mode 100644 common/uri/src/main/java/io/helidon/common/uri/UriValidator.java create mode 100644 common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java create mode 100644 webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java b/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java index eb44bb1c0c0..11b1f5d19e1 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ private UriFragment(String encoded, String fragment) { * @return a new instance */ public static UriFragment create(String rawFragment) { + Objects.requireNonNull(rawFragment); return new UriFragment(rawFragment); } @@ -104,6 +105,9 @@ public boolean hasValue() { * @return encoded fragment */ public String rawValue() { + if (rawFragment == null) { + throw new IllegalStateException("UriFragment does not have a value, guard with hasValue()"); + } return rawFragment; } @@ -114,7 +118,7 @@ public String rawValue() { */ public String value() { if (decodedFragment == null) { - decodedFragment = UriEncoding.decodeUri(rawFragment); + decodedFragment = UriEncoding.decodeUri(rawValue()); } return decodedFragment; } diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java b/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java index 2b41d70578a..f1e24fa43a2 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,17 +31,33 @@ public interface UriQuery extends Parameters { /** * Create a new HTTP query from the query string. + * This method does not validate the raw query against specification. * * @param query raw query string * @return HTTP query instance + * @see #create(String, boolean) */ static UriQuery create(String query) { + return create(query, false); + } + + /** + * Create a new HTTP query from the query string, validating if requested. + * + * @param query raw query string + * @param validate whether to validate that the query is according to the specification + * @return HTTP query instance + */ + static UriQuery create(String query, boolean validate) { Objects.requireNonNull(query, "Raw query string cannot be null, use create(URI) or empty()"); if (query.isEmpty()) { return empty(); } + if (validate) { + return new UriQueryImpl(query).validate(); + } return new UriQueryImpl(query); } diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java b/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java index fd6544cc808..cba7853848b 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java @@ -190,6 +190,11 @@ public String toString() { return "?" + rawValue(); } + UriQuery validate() { + UriValidator.validateQuery(query); + return this; + } + private void ensureDecoded() { if (decodedQueryParams == null) { Map> newQueryParams = new HashMap<>(); diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java b/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java new file mode 100644 index 00000000000..8393d91297d --- /dev/null +++ b/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.common.uri; + +import java.util.Objects; + +import static io.helidon.common.uri.UriValidator.encode; +import static io.helidon.common.uri.UriValidator.print; + +/** + * A URI validation exception. + *

+ * This type provides access to the invalid value that is not cleaned, through {@link #invalidValue()}. + * The exception message is cleaned and can be logged and returned to users ({@link #getMessage()}). + * + * @see #invalidValue() + */ +public class UriValidationException extends IllegalArgumentException { + /** + * Segment that failed validation. + */ + private final Segment segment; + /** + * The value (containing illegal characters) that failed validation. + */ + private final char[] invalidValue; + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * The message provided will be appended with cleaned invalid value in double quotes. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param message descriptive message + */ + public UriValidationException(Segment segment, char[] invalidValue, String message) { + super(toMessage(invalidValue, message)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param validated a validated section of the full value + * @param message descriptive message + */ + UriValidationException(Segment segment, char[] invalidValue, char[] validated, String message) { + super(toMessage(invalidValue, validated, message)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param validated a validated section of the full value + * @param message descriptive message + * @param index index in the {@code validated} array that failed + * @param c character that was invalid + */ + UriValidationException(Segment segment, char[] invalidValue, char[] validated, String message, int index, char c) { + super(toMessage(invalidValue, validated, message, index, c)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param message descriptive message + * @param index index in the {@code invalidValue} array that failed + * @param c character that was invalid + */ + UriValidationException(Segment segment, char[] invalidValue, String message, int index, char c) { + super(toMessage(invalidValue, message, index, c)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * The value that did not pass validation. + * This value is as it was received over the network, so it is not safe to log or return to the user! + * + * @return invalid value that failed validation + */ + public char[] invalidValue() { + return invalidValue; + } + + /** + * Segment that caused this validation exception. + * + * @return segment of the URI + */ + public Segment segment() { + return segment; + } + + private static String toMessage(char[] value, String message) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + + if (value.length == 0) { + return message; + } + return message + ": " + encode(value); + } + + private static String toMessage(char[] value, char[] validated, String message) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + Objects.requireNonNull(validated); + + if (validated.length == 0) { + if (value.length == 0) { + return message; + } + return message + ". Value: " + encode(value); + } + if (value.length == 0) { + return message + ": " + encode(validated); + } + return message + ": " + encode(validated) + + ". Value: " + encode(value); + } + + private static String toMessage(char[] value, char[] validated, String message, int index, char c) { + Objects.requireNonNull(value); + Objects.requireNonNull(validated); + Objects.requireNonNull(message); + + return message + ": " + encode(validated) + ", index: " + index + + ", char: " + print(c) + + ". Value: " + encode(value); + } + + private static String toMessage(char[] value, String message, int index, char c) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + + return message + ": " + encode(value) + ", index: " + index + + ", char: " + print(c); + } + + /** + * Segment of the URI that caused this validation failure. + */ + public enum Segment { + /** + * URI Scheme. + */ + SCHEME("Scheme"), + /** + * URI Host. + */ + HOST("Host"), + /** + * URI Path. + */ + PATH("Path"), + /** + * URI Query. + */ + QUERY("Query"), + /** + * URI Fragment. + */ + FRAGMENT("Fragment"); + private final String name; + + Segment(String name) { + this.name = name; + } + + /** + * Human-readable text that describes this segment. + * + * @return segment text + */ + public String text() { + return name; + } + } +} diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java b/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java new file mode 100644 index 00000000000..c2032a8ba67 --- /dev/null +++ b/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java @@ -0,0 +1,653 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.common.uri; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.common.uri.UriValidationException.Segment; + +/** + * Validate parts of the URI. + *

+ * Validation is based on + * RFC-3986. + *

+ * The following list provides an overview of parts of URI and how/if we validate it: + *

    + *
  • scheme - {@link #validateScheme(String)}
  • + *
  • authority - {@link #validateHost(String)}, port is validated in HTTP processing
  • + *
  • path - see {@link io.helidon.common.uri.UriPath#validate()}
  • + *
  • query - {@link #validateQuery(String)}
  • + *
  • fragment - {@link #validateFragment(String)}
  • + *
+ */ +public final class UriValidator { + private static final Pattern IP_V4_PATTERN = + Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$"); + private static final boolean[] HEXDIGIT = new boolean[256]; + private static final boolean[] UNRESERVED = new boolean[256]; + private static final boolean[] SUB_DELIMS = new boolean[256]; + // characters (in addition to hex, unreserved and sub-delims) that can be safely printed + private static final boolean[] PRINTABLE = new boolean[256]; + + static { + // digits + for (int i = '0'; i <= '9'; i++) { + UNRESERVED[i] = true; + } + // alpha + for (int i = 'a'; i <= 'z'; i++) { + UNRESERVED[i] = true; + } + for (int i = 'A'; i <= 'Z'; i++) { + UNRESERVED[i] = true; + } + UNRESERVED['-'] = true; + UNRESERVED['.'] = true; + UNRESERVED['_'] = true; + UNRESERVED['~'] = true; + + // hexdigits + // digits + for (int i = '0'; i <= '9'; i++) { + HEXDIGIT[i] = true; + } + // alpha + for (int i = 'a'; i <= 'f'; i++) { + HEXDIGIT[i] = true; + } + for (int i = 'A'; i <= 'F'; i++) { + HEXDIGIT[i] = true; + } + + // sub-delim set + SUB_DELIMS['!'] = true; + SUB_DELIMS['$'] = true; + SUB_DELIMS['&'] = true; + SUB_DELIMS['\''] = true; + SUB_DELIMS['('] = true; + SUB_DELIMS[')'] = true; + SUB_DELIMS['*'] = true; + SUB_DELIMS['+'] = true; + SUB_DELIMS[','] = true; + SUB_DELIMS[';'] = true; + SUB_DELIMS['='] = true; + + PRINTABLE[':'] = true; + PRINTABLE['/'] = true; + PRINTABLE['?'] = true; + PRINTABLE['@'] = true; + PRINTABLE['%'] = true; + PRINTABLE['#'] = true; + PRINTABLE['['] = true; + PRINTABLE[']'] = true; + } + + private UriValidator() { + } + + /** + * Validate a URI scheme. + * + * @param scheme scheme to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the scheme + */ + public static void validateScheme(String scheme) { + if ("http".equals(scheme)) { + return; + } + if ("https".equals(scheme)) { + return; + } + // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + char[] chars = scheme.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.SCHEME, chars, i, c); + if (Character.isLetterOrDigit(c)) { + continue; + } + if (c == '+') { + continue; + } + if (c == '-') { + continue; + } + if (c == '.') { + continue; + } + failInvalidChar(Segment.SCHEME, chars, i, c); + } + } + + /** + * Validate a URI Query raw string. + * + * @param rawQuery query to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the query + */ + public static void validateQuery(String rawQuery) { + Objects.requireNonNull(rawQuery); + + // empty query is valid + if (rawQuery.isEmpty()) { + return; + } + + // query = *( pchar / "/" / "?" ) + // pchar = unreserved / pct-encoded / sub-delims / "@" + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + // pct-encoded = "%" HEXDIG HEXDIG + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + + char[] chars = rawQuery.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.QUERY, chars, i, c); + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '@') { + continue; + } + if (c == '/') { + continue; + } + if (c == '?') { + continue; + } + // done with pchar validation except for percent encoded + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.QUERY, rawQuery, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.QUERY, chars, i, c); + } + } + + /** + * Validate a host string. + * + * @param host host to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateHost(String host) { + Objects.requireNonNull(host); + if (host.indexOf('[') == 0 && host.indexOf(']') == host.length() - 1) { + validateIpLiteral(host); + } else { + validateNonIpLiteral(host); + } + } + + /** + * An IP literal starts with {@code [} and ends with {@code ]}. + * + * @param ipLiteral host literal string, may be an IPv6 address, or IP version future + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateIpLiteral(String ipLiteral) { + Objects.requireNonNull(ipLiteral); + checkNotBlank(Segment.HOST, "IP Literal", ipLiteral, ipLiteral); + + // IP-literal = "[" ( IPv6address / IPvFuture ) "]" + if (ipLiteral.charAt(0) != '[') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Invalid IP literal, missing square bracket(s)", + 0, + ipLiteral.charAt(0)); + } + int lastIndex = ipLiteral.length() - 1; + if (ipLiteral.charAt(lastIndex) != ']') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Invalid IP literal, missing square bracket(s)", + lastIndex, + ipLiteral.charAt(lastIndex)); + } + + String host = ipLiteral.substring(1, ipLiteral.length() - 1); + checkNotBlank(Segment.HOST, "Host", ipLiteral, host); + if (host.charAt(0) == 'v') { + // IP future - starts with version `v1` etc. + validateIpFuture(ipLiteral, host); + return; + } + // IPv6 + /* + IPv6address = 6( h16 ":" ) ls32 + / "::" 5( h16 ":" ) ls32 + / [ h16 ] "::" 4( h16 ":" ) ls32 + / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + / [ *4( h16 ":" ) h16 ] "::" ls32 + / [ *5( h16 ":" ) h16 ] "::" h16 + / [ *6( h16 ":" ) h16 ] "::" + + ls32 = ( h16 ":" h16 ) / IPv4address + h16 = 1*4HEXDIG + */ + if (host.equals("::")) { + // all empty + return; + } + if (host.equals("::1")) { + // localhost + return; + } + boolean skipped = false; + int segments = 0; // max segments is 8 (full IPv6 address) + String inProgress = host; + while (!inProgress.isEmpty()) { + if (inProgress.length() == 1) { + segments++; + validateH16(ipLiteral, inProgress); + break; + } + if (inProgress.charAt(0) == ':' && inProgress.charAt(1) == ':') { + // :: means skip everything that was before (or everything that is after) + if (skipped) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 contains more than one skipped segment"); + } + skipped = true; + segments++; + inProgress = inProgress.substring(2); + continue; + } + if (inProgress.charAt(0) == ':') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + inProgress.toCharArray(), + "Host IPv6 contains excessive colon"); + } + // this must be h16 (or an IPv4 address) + int nextColon = inProgress.indexOf(':'); + if (nextColon == -1) { + // the rest of the string + if (inProgress.indexOf('.') == -1) { + segments++; + validateH16(ipLiteral, inProgress); + } else { + Matcher matcher = IP_V4_PATTERN.matcher(inProgress); + if (matcher.matches()) { + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(1)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(2)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(3)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(4)); + } else { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 dual address contains invalid IPv4 address"); + } + } + break; + } + validateH16(ipLiteral, inProgress.substring(0, nextColon)); + segments++; + if (inProgress.length() >= nextColon + 2) { + if (inProgress.charAt(nextColon + 1) == ':') { + // double colon, keep it there + inProgress = inProgress.substring(nextColon); + continue; + } + } + inProgress = inProgress.substring(nextColon + 1); + if (inProgress.isBlank()) { + // this must fail on empty segment + validateH16(ipLiteral, inProgress); + } + } + + if (segments > 8) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 address contains too many segments"); + } + } + + /** + * Validate IPv4 address or a registered name. + * + * @param host string with either an IPv4 address, or a registered name + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateNonIpLiteral(String host) { + Objects.requireNonNull(host); + checkNotBlank(Segment.HOST, "Host", host, host); + + // Ipv4 address: 127.0.0.1 + Matcher matcher = IP_V4_PATTERN.matcher(host); + if (matcher.matches()) { + /* + IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet + dec-octet = DIGIT ; 0-9 + / %x31-39 DIGIT ; 10-99 + / "1" 2DIGIT ; 100-199 + / "2" %x30-34 DIGIT ; 200-249 + / "25" %x30-35 ; 250-255 + */ + + // we have found an IPv4 address, or a valid registered name (555.555.555.555 is a valid name...) + return; + } + + // everything else is a registered name + + // registered name + /* + reg-name = *( unreserved / pct-encoded / sub-delims ) + pct-encoded = "%" HEXDIG HEXDIG + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" + */ + char[] chars = host.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.HOST, chars, i, c); + + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.HOST, host, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.HOST, chars, i, c); + } + } + + /** + * Validate URI fragment. + * + * @param rawFragment fragment to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the fragment + */ + public static void validateFragment(String rawFragment) { + Objects.requireNonNull(rawFragment); + + if (rawFragment.isEmpty()) { + return; + } + char[] chars = rawFragment.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateAscii(Segment.FRAGMENT, chars, i, c); + + // *( pchar / "/" / "?" ) + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '@') { + continue; + } + if (c == ':') { + continue; + } + + // done with pchar validation except for percent encoded + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.FRAGMENT, rawFragment, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.FRAGMENT, chars, i, c); + } + } + + static String print(char c) { + if (printable(c)) { + return "'" + c + "'"; + } + return "0x" + hex(c); + } + + static String encode(char[] chars) { + StringBuilder result = new StringBuilder(chars.length); + + for (char aChar : chars) { + if (aChar > 254) { + result.append('?'); + continue; + } + if (printable(aChar)) { + result.append(aChar); + continue; + } + result.append('?'); + } + + return result.toString(); + } + + private static void failInvalidChar(Segment segment, char[] chars, int i, char c) { + throw new UriValidationException(segment, + chars, + segment.text() + " contains invalid char", + i, + c); + } + + private static void validateAscii(Segment segment, char[] chars, int i, char c) { + if (c > 254) { + // in general only ASCII characters are allowed + throw new UriValidationException(segment, + chars, + segment.text() + " contains invalid char (non-ASCII)", + i, + c); + } + } + + /** + * Validate percent encoding sequence. + * + * @param segment segment of the URI + * @param chars characters of the part + * @param i index of the percent + */ + private static void validatePercentEncoding(Segment segment, String value, char[] chars, int i) { + if (i + 2 >= chars.length) { + throw new UriValidationException(segment, + chars, + segment.text() + + " contains invalid % encoding, not enough chars left at index " + + i); + } + char p1 = chars[i + 1]; + char p2 = chars[i + 2]; + // %p1p2 + validateHex(segment, value, chars, p1, segment.text(), i + 1, true); + validateHex(segment, value, chars, p2, segment.text(), i + 2, true); + } + + private static void validateHex(Segment segment, + String fullValue, + char[] chars, + char c, + String type, + int index, + boolean isPercentEncoding) { + if (c > 255 || !HEXDIGIT[c]) { + if (fullValue.length() == chars.length) { + if (isPercentEncoding) { + throw new UriValidationException(segment, + chars, + type + " has non hexadecimal char in % encoding", + index, + c); + } + throw new UriValidationException(segment, + chars, + type + " has non hexadecimal char", + index, + c); + } else { + if (isPercentEncoding) { + throw new UriValidationException(segment, + fullValue.toCharArray(), + chars, + type + " has non hexadecimal char in % encoding", + index, + c); + } + throw new UriValidationException(segment, + fullValue.toCharArray(), + chars, + type + " has non hexadecimal char", + index, + c); + } + } + } + + private static String hex(char c) { + String hexString = Integer.toHexString(c); + if (hexString.length() == 1) { + return "0" + hexString; + } + return hexString; + } + + private static void validateH16(String host, String inProgress) { + if (inProgress.isBlank()) { + throw new UriValidationException(Segment.HOST, + host.toCharArray(), + "IPv6 segment is empty"); + } + if (inProgress.length() > 4) { + throw new UriValidationException(Segment.HOST, + host.toCharArray(), + inProgress.toCharArray(), + "IPv6 segment has more than 4 chars"); + } + validateHexDigits(Segment.HOST, "IPv6 segment", host, inProgress); + } + + private static void validateHexDigits(Segment segment, + String description, + String host, + String section) { + char[] chars = section.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateHex(segment, host, chars, c, description, i, false); + } + } + + private static void validateIpOctet(String message, String host, String octet) { + int octetInt = Integer.parseInt(octet); + // cannot be negative, as the regexp will not match + if (octetInt > 255) { + throw new UriValidationException(Segment.HOST, host.toCharArray(), message); + } + } + + private static void validateIpFuture(String ipLiteral, String host) { + /* + IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + */ + int dot = host.indexOf('.'); + if (dot == -1) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "IP Future must contain 'v.'"); + } + // always starts with v + String version = host.substring(1, dot); + checkNotBlank(Segment.HOST, "Version", ipLiteral, version); + validateHexDigits(Segment.HOST, "Future version", ipLiteral, version); + + String address = host.substring(dot + 1); + checkNotBlank(Segment.HOST, "IP Future", ipLiteral, address); + + char[] chars = address.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateAscii(Segment.HOST, chars, i, c); + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == ':') { + continue; + } + failInvalidChar(Segment.HOST, ipLiteral.toCharArray(), i + dot + 1, c); + } + } + + private static void checkNotBlank(Segment segment, + String message, + String ipLiteral, + String toValidate) { + if (toValidate.isBlank()) { + if (ipLiteral.equals(toValidate)) { + throw new UriValidationException(segment, ipLiteral.toCharArray(), message + " cannot be blank"); + } else { + throw new UriValidationException(segment, + ipLiteral.toCharArray(), + toValidate.toCharArray(), + message + " cannot be blank"); + } + } + } + + private static boolean printable(char c) { + if (c > 254) { + return false; + } + if (UNRESERVED[c]) { + return true; + } + if (SUB_DELIMS[c]) { + return true; + } + if (PRINTABLE[c]) { + return true; + } + return false; + } +} diff --git a/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java b/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java index cdbee2474a7..3c011ac81d0 100644 --- a/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java +++ b/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java @@ -103,5 +103,4 @@ void testFromQueryString() { assertThat(query.get("p4"), is("a b c")); assertThat(query.getRaw("p4"), is("a%20b%20c")); } - } \ No newline at end of file diff --git a/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java b/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java new file mode 100644 index 00000000000..659e5a98249 --- /dev/null +++ b/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.common.uri; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import static io.helidon.common.uri.UriValidator.validateFragment; +import static io.helidon.common.uri.UriValidator.validateHost; +import static io.helidon.common.uri.UriValidator.validateIpLiteral; +import static io.helidon.common.uri.UriValidator.validateScheme; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UriValidatorTest { + @Test + void testSchemeValidation() { + validateScheme("http"); + validateScheme("https"); + validateScheme("ws"); + validateScheme("abc123+-."); + + assertThrows(NullPointerException.class, () -> UriValidator.validateScheme(null)); + + validateBadScheme("čttp", + "Scheme contains invalid char (non-ASCII): ?ttp, index: 0, char: 0x10d"); + validateBadScheme("h_ttp", + "Scheme contains invalid char: h_ttp, index: 1, char: '_'"); + validateBadScheme("h~ttp", + "Scheme contains invalid char: h~ttp, index: 1, char: '~'"); + validateBadScheme("h!ttp", + "Scheme contains invalid char: h!ttp, index: 1, char: '!'"); + } + + @Test + void testFragmentValidation() { + assertThrows(NullPointerException.class, () -> validateFragment(null)); + validateFragment(""); + validateFragment("fragment"); + validateFragment("frag_ment"); // unreserved + validateFragment("frag~ment"); // unreserved + validateFragment("frag=ment"); // sub-delim + validateFragment("frag=!"); // sub-delim + validateFragment("frag%61ment"); // pct-encoded + validateFragment("frag@ment"); // at sign + validateFragment("frag:ment"); // colon + + validateBadFragment("fragčment", + "Fragment contains invalid char (non-ASCII): frag?ment, index: 4, char: 0x10d"); + validateBadFragment("frag%6147%4", + "Fragment contains invalid % encoding, not enough chars left at index 9: frag%6147%4"); + // percent encoded: first char is invalid + validateBadFragment("frag%6147%X1", + "Fragment has non hexadecimal char in % encoding: frag%6147%X1, index: 10, char: 'X'"); + validateBadFragment("frag%6147%č1", + "Fragment has non hexadecimal char in % encoding: frag%6147%?1, index: 10, char: 0x10d"); + // percent encoded: second char is invalid + validateBadFragment("frag%6147%1X", + "Fragment has non hexadecimal char in % encoding: frag%6147%1X, index: 11, char: 'X'"); + validateBadFragment("frag%6147%1č", + "Fragment has non hexadecimal char in % encoding: frag%6147%1?, index: 11, char: 0x10d"); + // character not in allowed sets + validateBadFragment("frag%6147{", + "Fragment contains invalid char: frag%6147?, index: 9, char: 0x7b"); + validateBadFragment("frag%6147\t", + "Fragment contains invalid char: frag%6147?, index: 9, char: 0x09"); + } + + @Test + void testQueryValidation() { + assertThrows(NullPointerException.class, () -> UriQuery.create((String) null)); + assertThrows(NullPointerException.class, () -> UriQuery.create((URI) null)); + assertThrows(NullPointerException.class, () -> UriQuery.create(null, true)); + assertThrows(NullPointerException.class, () -> UriValidator.validateQuery(null)); + + UriQuery.create("", true); + UriValidator.validateQuery(""); + UriQuery.create("a=b&c=d&a=e", true); + // validate all rules + // must be an ASCII (lower than 255) + validateBadQuery("a=@/?%6147č", + "Query contains invalid char (non-ASCII): a=@/?%6147?, index: 10, char: 0x10d"); + // percent encoded: must be full percent encoding + validateBadQuery("a=@/?%6147%4", + "Query contains invalid % encoding, not enough chars left at index 10: a=@/?%6147%4"); + // percent encoded: first char is invalid + validateBadQuery("a=@/?%6147%X1", + "Query has non hexadecimal char in % encoding: a=@/?%6147%X1, index: 11, char: 'X'"); + validateBadQuery("a=@/?%6147%č1", + "Query has non hexadecimal char in % encoding: a=@/?%6147%?1, index: 11, char: 0x10d"); + // percent encoded: second char is invalid + validateBadQuery("a=@/?%6147%1X", + "Query has non hexadecimal char in % encoding: a=@/?%6147%1X, index: 12, char: 'X'"); + validateBadQuery("a=@/?%6147%1č", + "Query has non hexadecimal char in % encoding: a=@/?%6147%1?, index: 12, char: 0x10d"); + // character not in allowed sets + validateBadQuery("a=@/?%6147{", + "Query contains invalid char: a=@/?%6147?, index: 10, char: 0x7b"); + validateBadQuery("a=@/?%6147\t", + "Query contains invalid char: a=@/?%6147?, index: 10, char: 0x09"); + } + + @Test + void testGoodHostname() { + // sanity + validateHost("localhost"); + // host names + validateHost("www.example.com"); + // percent encoded + validateHost("%65%78%61%6D%70%6C%65"); + validateHost("%65%78%61%6D%70%6C%65.com"); + // with underscores + validateHost("www.exa_mple.com"); + // with sub-delims + validateHost("www.exa$mple.com"); + } + + @Test + void testGoodIp4() { + // IPv4 + validateHost("192.167.1.1"); + } + + @Test + void testGoodIpLiteral6() { + // IPv6 + validateHost("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); + validateHost("[::1]"); + validateHost("[2001:db8:3333:4444:5555:6666:7777:8888]"); + validateHost("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); + validateHost("[::]"); + validateHost("[2001:db8::]"); + validateHost("[::1234:5678]"); + validateHost("[::1234:5678:1]"); + validateHost("[2001:db8::1234:5678]"); + validateHost("[2001:db8:1::ab9:C0A8:102]"); + } + + @Test + void testGoodIpLiteral6Dual() { + // IPv6 + validateHost("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); + validateHost("[::11.22.33.44]"); + validateHost("[2001:db8::123.123.123.123]"); + validateHost("[::1234:5678:91.123.4.56]"); + validateHost("[::1234:5678:1.2.3.4]"); + validateHost("[2001:db8::1234:5678:5.6.7.8]"); + } + + @Test + void testGoodIpLiteralFuture() { + // IPvFuture + validateHost("[v9.abc:def]"); + validateHost("[v9.abc:def*]"); + } + + @Test + void testBadHosts() { + // just empty + invokeExpectFailure("Host cannot be blank", ""); + // invalid brackets + invokeExpectFailure("Host contains invalid char: [start.but.not.end, index: 0, char: '['", + "[start.but.not.end"); + invokeExpectFailure("Host contains invalid char: end.but.not.start], index: 17, char: ']'", + "end.but.not.start]"); + invokeExpectFailure("Host contains invalid char: int.the[.middle], index: 7, char: '['", + "int.the[.middle]"); + // invalid escape + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%ZAxample.com, index: 5, char: 'Z'", + "www.%ZAxample.com"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%AZxample.com, index: 6, char: 'Z'", + "www.%AZxample.com"); + // invalid character (non-ASCII + invokeExpectFailure("Host contains invalid char (non-ASCII): www.?example.com, index: 4, char: 0x10d", + "www.čexample.com"); + // wrong trailing escape (must be two chars); + invokeExpectFailure("Host contains invalid % encoding, not enough chars left at index 15: www.example.com%4", + "www.example.com%4"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%?4, index: 16, char: 0x10d", + "www.example.com%č4"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%4?, index: 17, char: 0x10d", + "www.example.com%4č"); + } + + @Test + void testBadLiteral6() { + // IPv6 + // empty segment + invokeExpectFailure("Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", + "[2001:db8::85a3::7334]"); + // wrong segment (G is not a hexadecimal number) + invokeExpectFailure("IPv6 segment has non hexadecimal char: GGGG, index: 0, char: 'G'. " + + "Value: [GGGG:FFFF:0000:0000:0000:0000:0000:0000]", + "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]"); + // non-ASCII character + invokeExpectFailure("IPv6 segment has non hexadecimal char: ?, index: 0, char: 0x10d. " + + "Value: [?:FFFF:0000:0000:0000:0000:0000:0000]", + "[č:FFFF:0000:0000:0000:0000:0000:0000]"); + // wrong segment (too many characters) + invokeExpectFailure("IPv6 segment has more than 4 chars: aaaaa. " + + "Value: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", + "[aaaaa:FFFF:0000:0000:0000:0000:0000:0000]"); + // empty segment + invokeExpectFailure("IPv6 segment is empty: [aaaa:FFFF:0000:0000:0000:0000:0000:]", + "[aaaa:FFFF:0000:0000:0000:0000:0000:]"); + // wrong number of segments + invokeExpectFailure("Host IPv6 address contains too many segments: " + + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]", + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]"); + // missing everything + invokeExpectFailure("Host cannot be blank. Value: []", + "[]"); + // wrong start (leading colon) + invokeExpectFailure("Host IPv6 contains excessive colon: :1:0::. Value: [:1:0::]", + "[:1:0::]"); + // wrong end, colon instead of value + invokeExpectFailure("IPv6 segment has non hexadecimal char: :, index: 0, char: ':'. Value: [1:0:::]", + "[1:0:::]"); + + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::, index: 2, char: ':'", + "[::"); + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::], index: 0, char: ':'", + "::]"); + } + + @Test + void testBadLiteralDual() { + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.266.44.74]", + "[::14.266.44.74]"); + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.266.44]", + "[::14.266.44]"); + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.123.-44.147]", + "[::14.123.-44.147]"); + } + + @Test + void testBadLiteralFuture() { + // IPv future + // version must be present + invokeExpectFailure("Version cannot be blank. Value: [v.abc:def]", + "[v.abc:def]"); + // missing address + invokeExpectFailure("IP Future must contain 'v.': [v2]", + "[v2]"); + invokeExpectFailure("IP Future cannot be blank. Value: [v2.]", + "[v2.]"); + // invalid character in the host (valid future) + invokeExpectFailure("Host contains invalid char: [v2./0:::], index: 3, char: '/'", + "[v2./0:::]"); + invokeExpectFailure("Host contains invalid char (non-ASCII): 0:?, index: 2, char: 0x10d", + "[v2.0:č]"); + } + + private static void validateBadQuery(String query, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> UriQuery.create(query, true)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void validateBadScheme(String scheme, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> validateScheme(scheme)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void validateBadFragment(String fragment, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> validateFragment(fragment)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void invokeExpectFailure(String message, String host) { + var t = assertThrows(IllegalArgumentException.class, () -> validateHost(host), "Testing host: " + host); + assertThat(t.getMessage(), is(message)); + } + + private static void invokeLiteralExpectFailure(String message, String host) { + var t = assertThrows(IllegalArgumentException.class, () -> validateIpLiteral(host), "Testing host: " + host); + assertThat(t.getMessage(), is(message)); + } +} \ No newline at end of file diff --git a/http/http/src/main/java/io/helidon/http/HostValidator.java b/http/http/src/main/java/io/helidon/http/HostValidator.java index 834a27b0b5a..3fac173f952 100644 --- a/http/http/src/main/java/io/helidon/http/HostValidator.java +++ b/http/http/src/main/java/io/helidon/http/HostValidator.java @@ -16,67 +16,18 @@ package io.helidon.http; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import io.helidon.common.uri.UriValidator; /** * Validate the host string (maybe from the {@code Host} header). *

* Validation is based on * RFC-3986. + * + * @deprecated use {@link io.helidon.common.uri.UriValidator} instead */ +@Deprecated(since = "4.1.5", forRemoval = true) public final class HostValidator { - private static final Pattern IP_V4_PATTERN = - Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$"); - private static final boolean[] HEXDIGIT = new boolean[256]; - private static final boolean[] UNRESERVED = new boolean[256]; - private static final boolean[] SUB_DELIMS = new boolean[256]; - - static { - // digits - for (int i = '0'; i <= '9'; i++) { - UNRESERVED[i] = true; - } - // alpha - for (int i = 'a'; i <= 'z'; i++) { - UNRESERVED[i] = true; - } - for (int i = 'A'; i <= 'Z'; i++) { - UNRESERVED[i] = true; - } - UNRESERVED['-'] = true; - UNRESERVED['.'] = true; - UNRESERVED['_'] = true; - UNRESERVED['~'] = true; - - // hexdigits - // digits - for (int i = '0'; i <= '9'; i++) { - HEXDIGIT[i] = true; - } - // alpha - for (int i = 'a'; i <= 'f'; i++) { - HEXDIGIT[i] = true; - } - for (int i = 'A'; i <= 'F'; i++) { - HEXDIGIT[i] = true; - } - - // sub-delim set - SUB_DELIMS['!'] = true; - SUB_DELIMS['$'] = true; - SUB_DELIMS['&'] = true; - SUB_DELIMS['\''] = true; - SUB_DELIMS['('] = true; - SUB_DELIMS[')'] = true; - SUB_DELIMS['*'] = true; - SUB_DELIMS['+'] = true; - SUB_DELIMS[','] = true; - SUB_DELIMS[';'] = true; - SUB_DELIMS['='] = true; - } - private HostValidator() { } @@ -84,264 +35,35 @@ private HostValidator() { * Validate a host string. * * @param host host to validate - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateHost(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validate(String host) { - Objects.requireNonNull(host); - if (host.indexOf('[') == 0 && host.indexOf(']') == host.length() - 1) { - validateIpLiteral(host); - } else { - validateNonIpLiteral(host); - } + UriValidator.validateHost(host); } /** * An IP literal starts with {@code [} and ends with {@code ]}. * * @param ipLiteral host literal string, may be an IPv6 address, or IP version future - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateIpLiteral(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validateIpLiteral(String ipLiteral) { - Objects.requireNonNull(ipLiteral); - checkNotBlank("IP Literal", ipLiteral, ipLiteral); - - // IP-literal = "[" ( IPv6address / IPvFuture ) "]" - if (ipLiteral.charAt(0) != '[' || ipLiteral.charAt(ipLiteral.length() - 1) != ']') { - throw new IllegalArgumentException("Invalid IP literal, missing square bracket(s): " + HtmlEncoder.encode(ipLiteral)); - } - - String host = ipLiteral.substring(1, ipLiteral.length() - 1); - checkNotBlank("Host", ipLiteral, host); - if (host.charAt(0) == 'v') { - // IP future - starts with version `v1` etc. - validateIpFuture(ipLiteral, host); - return; - } - // IPv6 - /* - IPv6address = 6( h16 ":" ) ls32 - / "::" 5( h16 ":" ) ls32 - / [ h16 ] "::" 4( h16 ":" ) ls32 - / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 - / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 - / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 - / [ *4( h16 ":" ) h16 ] "::" ls32 - / [ *5( h16 ":" ) h16 ] "::" h16 - / [ *6( h16 ":" ) h16 ] "::" - - ls32 = ( h16 ":" h16 ) / IPv4address - h16 = 1*4HEXDIG - */ - if (host.equals("::")) { - // all empty - return; - } - if (host.equals("::1")) { - // localhost - return; - } - boolean skipped = false; - int segments = 0; // max segments is 8 (full IPv6 address) - String inProgress = host; - while (!inProgress.isEmpty()) { - if (inProgress.length() == 1) { - segments++; - validateH16(ipLiteral, inProgress); - break; - } - if (inProgress.charAt(0) == ':' && inProgress.charAt(1) == ':') { - // :: means skip everything that was before (or everything that is after) - if (skipped) { - throw new IllegalArgumentException("Host IPv6 contains more than one skipped segment: " - + HtmlEncoder.encode(ipLiteral)); - } - skipped = true; - segments++; - inProgress = inProgress.substring(2); - continue; - } - if (inProgress.charAt(0) == ':') { - throw new IllegalArgumentException("Host IPv6 contains excessive colon: " + HtmlEncoder.encode(ipLiteral)); - } - // this must be h16 (or an IPv4 address) - int nextColon = inProgress.indexOf(':'); - if (nextColon == -1) { - // the rest of the string - if (inProgress.indexOf('.') == -1) { - segments++; - validateH16(ipLiteral, inProgress); - } else { - Matcher matcher = IP_V4_PATTERN.matcher(inProgress); - if (matcher.matches()) { - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(1)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(2)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(3)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(4)); - } else { - throw new IllegalArgumentException("Host IPv6 dual address contains invalid IPv4 address: " - + HtmlEncoder.encode(ipLiteral)); - } - } - break; - } - validateH16(ipLiteral, inProgress.substring(0, nextColon)); - segments++; - if (inProgress.length() >= nextColon + 2) { - if (inProgress.charAt(nextColon + 1) == ':') { - // double colon, keep it there - inProgress = inProgress.substring(nextColon); - continue; - } - } - inProgress = inProgress.substring(nextColon + 1); - if (inProgress.isBlank()) { - // this must fail on empty segment - validateH16(ipLiteral, inProgress); - } - } - - if (segments > 8) { - throw new IllegalArgumentException("Host IPv6 address contains too many segments: " + HtmlEncoder.encode(ipLiteral)); - } + UriValidator.validateIpLiteral(ipLiteral); } /** * Validate IPv4 address or a registered name. * * @param host string with either an IPv4 address, or a registered name - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateNonIpLiteral(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validateNonIpLiteral(String host) { - Objects.requireNonNull(host); - checkNotBlank("Host", host, host); - - // Ipv4 address: 127.0.0.1 - Matcher matcher = IP_V4_PATTERN.matcher(host); - if (matcher.matches()) { - /* - IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet - dec-octet = DIGIT ; 0-9 - / %x31-39 DIGIT ; 10-99 - / "1" 2DIGIT ; 100-199 - / "2" %x30-34 DIGIT ; 200-249 - / "25" %x30-35 ; 250-255 - */ - - // we have found an IPv4 address, or a valid registered name (555.555.555.555 is a valid name...) - return; - } - - // everything else is a registered name - - // registered name - /* - reg-name = *( unreserved / pct-encoded / sub-delims ) - pct-encoded = "%" HEXDIG HEXDIG - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - / "*" / "+" / "," / ";" / "=" - */ - char[] charArray = host.toCharArray(); - for (int i = 0; i < charArray.length; i++) { - char c = charArray[i]; - if (c > 255) { - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(host)); - } - if (UNRESERVED[c]) { - continue; - } - if (SUB_DELIMS[c]) { - continue; - } - if (c == '%') { - // percent encoding - if (i + 2 >= charArray.length) { - throw new IllegalArgumentException("Host contains invalid % encoding: " + HtmlEncoder.encode(host)); - } - char p1 = charArray[++i]; - char p2 = charArray[++i]; - // %p1p2 - if (p1 > 255 || p2 > 255) { - throw new IllegalArgumentException("Host contains invalid character in % encoding: " - + HtmlEncoder.encode(host)); - } - if (HEXDIGIT[p1] && HEXDIGIT[p2]) { - continue; - } - throw new IllegalArgumentException("Host contains non-hexadecimal character in % encoding: " - + HtmlEncoder.encode(host)); - } - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(host)); - } - } - - private static void validateH16(String host, String inProgress) { - if (inProgress.isBlank()) { - throw new IllegalArgumentException("IPv6 segment is empty: " + HtmlEncoder.encode(host)); - } - if (inProgress.length() > 4) { - throw new IllegalArgumentException("IPv6 segment has more than 4 characters: " + HtmlEncoder.encode(host)); - } - validateHexDigits("IPv6 segment", host, inProgress); - } - - private static void validateHexDigits(String description, String host, String segment) { - for (char c : segment.toCharArray()) { - if (c > 255) { - throw new IllegalArgumentException(description + " non hexadecimal character: " + HtmlEncoder.encode(host)); - } - if (!HEXDIGIT[c]) { - throw new IllegalArgumentException(description + " non hexadecimal character: " + HtmlEncoder.encode(host)); - } - } - } - - private static void validateIpOctet(String message, String host, String octet) { - int octetInt = Integer.parseInt(octet); - // cannot be negative, as the regexp will not match - if (octetInt > 255) { - throw new IllegalArgumentException(message + " " + HtmlEncoder.encode(host)); - } - } - - private static void validateIpFuture(String ipLiteral, String host) { - /* - IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" - */ - int dot = host.indexOf('.'); - if (dot == -1) { - throw new IllegalArgumentException("IP Future must contain 'v.': " + HtmlEncoder.encode(ipLiteral)); - } - // always starts with v - String version = host.substring(1, dot); - checkNotBlank("Version", ipLiteral, version); - validateHexDigits("Future version", ipLiteral, version); - - String address = host.substring(dot + 1); - checkNotBlank("IP Future", ipLiteral, address); - - for (char c : address.toCharArray()) { - if (c > 255) { - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(ipLiteral)); - } - if (UNRESERVED[c]) { - continue; - } - if (SUB_DELIMS[c]) { - continue; - } - if (c == ':') { - continue; - } - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(ipLiteral)); - } - } - - private static void checkNotBlank(String message, String ipLiteral, String toValidate) { - if (toValidate.isBlank()) { - throw new IllegalArgumentException(message + " cannot be blank: " + HtmlEncoder.encode(ipLiteral)); - } + UriValidator.validateNonIpLiteral(host); } } diff --git a/http/http/src/main/java/io/helidon/http/HttpPrologue.java b/http/http/src/main/java/io/helidon/http/HttpPrologue.java index 0f241b154d0..517fbe459fd 100644 --- a/http/http/src/main/java/io/helidon/http/HttpPrologue.java +++ b/http/http/src/main/java/io/helidon/http/HttpPrologue.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,7 +75,7 @@ private HttpPrologue(String rawProtocol, this.method = httpMethod; this.uriPath = uriPath; this.rawQuery = uriQuery.rawValue(); - this.rawFragment = uriFragment.rawValue(); + this.rawFragment = uriFragment.hasValue() ? uriFragment.rawValue() : null; this.fragment = uriFragment; this.query = uriQuery; diff --git a/http/http/src/test/java/io/helidon/http/HostValidatorTest.java b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java index f0144f9e787..83c1efb9887 100644 --- a/http/http/src/test/java/io/helidon/http/HostValidatorTest.java +++ b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java @@ -18,92 +18,91 @@ import org.junit.jupiter.api.Test; -import static io.helidon.http.HostValidator.validate; -import static io.helidon.http.HostValidator.validateIpLiteral; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +@SuppressWarnings("removal") class HostValidatorTest { @Test void testGoodHostname() { // sanity - validate("localhost"); + io.helidon.http.HostValidator.validate("localhost"); // host names - validate("www.example.com"); + io.helidon.http.HostValidator.validate("www.example.com"); // percent encoded - validate("%65%78%61%6D%70%6C%65"); - validate("%65%78%61%6D%70%6C%65.com"); + io.helidon.http.HostValidator.validate("%65%78%61%6D%70%6C%65"); + io.helidon.http.HostValidator.validate("%65%78%61%6D%70%6C%65.com"); // with underscores - validate("www.exa_mple.com"); + io.helidon.http.HostValidator.validate("www.exa_mple.com"); // with sub-delims - validate("www.exa$mple.com"); + io.helidon.http.HostValidator.validate("www.exa$mple.com"); } @Test void testGoodIp4() { // IPv4 - validate("192.167.1.1"); + io.helidon.http.HostValidator.validate("192.167.1.1"); } @Test void testGoodIpLiteral6() { // IPv6 - validate("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); - validate("[::1]"); - validate("[2001:db8:3333:4444:5555:6666:7777:8888]"); - validate("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); - validate("[::]"); - validate("[2001:db8::]"); - validate("[::1234:5678]"); - validate("[::1234:5678:1]"); - validate("[2001:db8::1234:5678]"); - validate("[2001:db8:1::ab9:C0A8:102]"); + io.helidon.http.HostValidator.validate("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); + io.helidon.http.HostValidator.validate("[::1]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:5555:6666:7777:8888]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); + io.helidon.http.HostValidator.validate("[::]"); + io.helidon.http.HostValidator.validate("[2001:db8::]"); + io.helidon.http.HostValidator.validate("[::1234:5678]"); + io.helidon.http.HostValidator.validate("[::1234:5678:1]"); + io.helidon.http.HostValidator.validate("[2001:db8::1234:5678]"); + io.helidon.http.HostValidator.validate("[2001:db8:1::ab9:C0A8:102]"); } @Test void testGoodIpLiteral6Dual() { // IPv6 - validate("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); - validate("[::11.22.33.44]"); - validate("[2001:db8::123.123.123.123]"); - validate("[::1234:5678:91.123.4.56]"); - validate("[::1234:5678:1.2.3.4]"); - validate("[2001:db8::1234:5678:5.6.7.8]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); + io.helidon.http.HostValidator.validate("[::11.22.33.44]"); + io.helidon.http.HostValidator.validate("[2001:db8::123.123.123.123]"); + io.helidon.http.HostValidator.validate("[::1234:5678:91.123.4.56]"); + io.helidon.http.HostValidator.validate("[::1234:5678:1.2.3.4]"); + io.helidon.http.HostValidator.validate("[2001:db8::1234:5678:5.6.7.8]"); } @Test void testGoodIpLiteralFuture() { // IPvFuture - validate("[v9.abc:def]"); - validate("[v9.abc:def*]"); + io.helidon.http.HostValidator.validate("[v9.abc:def]"); + io.helidon.http.HostValidator.validate("[v9.abc:def*]"); } @Test void testBadHosts() { // just empty - invokeExpectFailure("Host cannot be blank: ", ""); + invokeExpectFailure("Host cannot be blank", ""); // invalid brackets - invokeExpectFailure("Host contains invalid character: [start.but.not.end", + invokeExpectFailure("Host contains invalid char: [start.but.not.end, index: 0, char: '['", "[start.but.not.end"); - invokeExpectFailure("Host contains invalid character: end.but.not.start]", + invokeExpectFailure("Host contains invalid char: end.but.not.start], index: 17, char: ']'", "end.but.not.start]"); - invokeExpectFailure("Host contains invalid character: int.the[.middle]", + invokeExpectFailure("Host contains invalid char: int.the[.middle], index: 7, char: '['", "int.the[.middle]"); // invalid escape - invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%ZAxample.com", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%ZAxample.com, index: 5, char: 'Z'", "www.%ZAxample.com"); - invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%AZxample.com", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%AZxample.com, index: 6, char: 'Z'", "www.%AZxample.com"); // invalid character (non-ASCII - invokeExpectFailure("Host contains invalid character: www.čexample.com", + invokeExpectFailure("Host contains invalid char (non-ASCII): www.?example.com, index: 4, char: 0x10d", "www.čexample.com"); // wrong trailing escape (must be two chars); - invokeExpectFailure("Host contains invalid % encoding: www.example.com%4", + invokeExpectFailure("Host contains invalid % encoding, not enough chars left at index 15: www.example.com%4", "www.example.com%4"); - invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%č4", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%?4, index: 16, char: 0x10d", "www.example.com%č4"); - invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%4č", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%4?, index: 17, char: 0x10d", "www.example.com%4č"); } @@ -114,15 +113,16 @@ void testBadLiteral6() { invokeExpectFailure("Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", "[2001:db8::85a3::7334]"); // wrong segment (G is not a hexadecimal number) - invokeExpectFailure("IPv6 segment non hexadecimal character: " - + "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: GGGG, index: 0, char: 'G'. " + + "Value: [GGGG:FFFF:0000:0000:0000:0000:0000:0000]", "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]"); // non-ASCII character - invokeExpectFailure("IPv6 segment non hexadecimal character: " - + "[č:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: ?, index: 0, char: 0x10d. " + + "Value: [?:FFFF:0000:0000:0000:0000:0000:0000]", "[č:FFFF:0000:0000:0000:0000:0000:0000]"); // wrong segment (too many characters) - invokeExpectFailure("IPv6 segment has more than 4 characters: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has more than 4 chars: aaaaa. " + + "Value: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", "[aaaaa:FFFF:0000:0000:0000:0000:0000:0000]"); // empty segment invokeExpectFailure("IPv6 segment is empty: [aaaa:FFFF:0000:0000:0000:0000:0000:]", @@ -132,18 +132,18 @@ void testBadLiteral6() { + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]", "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]"); // missing everything - invokeExpectFailure("Host cannot be blank: []", + invokeExpectFailure("Host cannot be blank. Value: []", "[]"); // wrong start (leading colon) - invokeExpectFailure("Host IPv6 contains excessive colon: [:1:0::]", + invokeExpectFailure("Host IPv6 contains excessive colon: :1:0::. Value: [:1:0::]", "[:1:0::]"); // wrong end, colon instead of value - invokeExpectFailure("IPv6 segment non hexadecimal character: [1:0:::]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: :, index: 0, char: ':'. Value: [1:0:::]", "[1:0:::]"); - invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::", + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::, index: 2, char: ':'", "[::"); - invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::]", + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::], index: 0, char: ':'", "::]"); } @@ -161,27 +161,31 @@ void testBadLiteralDual() { void testBadLiteralFuture() { // IPv future // version must be present - invokeExpectFailure("Version cannot be blank: [v.abc:def]", + invokeExpectFailure("Version cannot be blank. Value: [v.abc:def]", "[v.abc:def]"); // missing address invokeExpectFailure("IP Future must contain 'v.': [v2]", "[v2]"); - invokeExpectFailure("IP Future cannot be blank: [v2.]", + invokeExpectFailure("IP Future cannot be blank. Value: [v2.]", "[v2.]"); // invalid character in the host (valid future) - invokeExpectFailure("Host contains invalid character: [v2./0:::]", + invokeExpectFailure("Host contains invalid char: [v2./0:::], index: 3, char: '/'", "[v2./0:::]"); - invokeExpectFailure("Host contains invalid character: [v2.0:č]", + invokeExpectFailure("Host contains invalid char (non-ASCII): 0:?, index: 2, char: 0x10d", "[v2.0:č]"); } private static void invokeExpectFailure(String message, String host) { - var t = assertThrows(IllegalArgumentException.class, () -> validate(host), "Testing host: " + host); + var t = assertThrows(IllegalArgumentException.class, + () -> io.helidon.http.HostValidator.validate(host), + "Testing host: " + host); assertThat(t.getMessage(), is(message)); } private static void invokeLiteralExpectFailure(String message, String host) { - var t = assertThrows(IllegalArgumentException.class, () -> validateIpLiteral(host), "Testing host: " + host); + var t = assertThrows(IllegalArgumentException.class, + () -> io.helidon.http.HostValidator.validateIpLiteral(host), + "Testing host: " + host); assertThat(t.getMessage(), is(message)); } } \ No newline at end of file diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java new file mode 100644 index 00000000000..80b83a712b0 --- /dev/null +++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.webserver.tests; + +import java.util.List; + +import io.helidon.common.testing.http.junit5.SocketHttpClient; +import io.helidon.http.HttpPrologue; +import io.helidon.http.Method; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http1.Http1Route; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +class BadPrologueTest { + private final Http1Client client; + private final SocketHttpClient socketClient; + + BadPrologueTest(Http1Client client, SocketHttpClient socketClient) { + this.client = client; + this.socketClient = socketClient; + } + + @SetUpRoute + static void routing(HttpRouting.Builder builder) { + builder.route(Http1Route.route(Method.GET, + "/", + (req, res) -> { + HttpPrologue prologue = req.prologue(); + String fragment = prologue.fragment().hasValue() + ? prologue.fragment().rawValue() + : ""; + res.send("path: " + prologue.uriPath().rawPath() + + ", query: " + prologue.query().rawValue() + + ", fragment: " + fragment); + })); + } + + @Test + void testOk() { + String response = client.method(Method.GET) + .requestEntity(String.class); + + assertThat(response, is("path: /, query: , fragment: ")); + } + + @Test + void testBadQuery() { + String response = socketClient.sendAndReceive(Method.GET, + "/?a=bad", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Query contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 2, char: 0x3c")); + assertThat(response, not(containsString(">"))); + } + + @Test + void testBadQueryCurly() { + String response = socketClient.sendAndReceive(Method.GET, + "/?name=test1{{", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Query contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 10, char: 0x7b")); + } + + @Test + void testBadPath() { + String response = socketClient.sendAndReceive(Method.GET, + "/name{{{{{{{Sdsds", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Fragment contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 16, char: 0x3e")); + assertThat(response, not(containsString(">"))); + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java index dc8a93daf42..915e69954d2 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java @@ -77,7 +77,7 @@ interface Http1ConfigBlueprint extends ProtocolConfig { * Request host header validation. * When host header is invalid, we return {@link io.helidon.http.Status#BAD_REQUEST_400}. *

- * The validation is done according to RFC-3986 (see {@link io.helidon.http.HostValidator}). This is a requirement of + * The validation is done according to RFC-3986 (see {@link io.helidon.common.uri.UriValidator}). This is a requirement of * the HTTP specification. *

* This option allows you to disable the "full-blown" validation ("simple" validation is still in - the port must be @@ -108,6 +108,17 @@ interface Http1ConfigBlueprint extends ProtocolConfig { @Option.DefaultBoolean(false) boolean validateResponseHeaders(); + /** + * If set to false, any query and fragment is accepted (even containing illegal characters). + * Validation of path is controlled by {@link #validatePath()}. + * + * @return whether to validate prologue query and fragment + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean validatePrologue(); + + /** * If set to false, any path is accepted (even containing illegal characters). * diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java index f0c4b13c37a..acc07c4c963 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java @@ -37,13 +37,13 @@ import io.helidon.common.mapper.MapperException; import io.helidon.common.task.InterruptableTask; import io.helidon.common.tls.TlsUtils; +import io.helidon.common.uri.UriValidator; import io.helidon.http.BadRequestException; import io.helidon.http.DateTime; import io.helidon.http.DirectHandler; import io.helidon.http.DirectHandler.EventType; import io.helidon.http.HeaderNames; import io.helidon.http.HeaderValues; -import io.helidon.http.HostValidator; import io.helidon.http.HtmlEncoder; import io.helidon.http.HttpPrologue; import io.helidon.http.InternalServerException; @@ -160,6 +160,10 @@ public void handle(Limit limit) throws InterruptedException { currentEntitySize = 0; currentEntitySizeRead = 0; + if (http1Config.validatePrologue()) { + validatePrologue(prologue); + } + WritableHeaders headers = http1headers.readHeaders(prologue); if (http1Config.validateRequestHeaders()) { validateHostHeader(prologue, headers, http1Config.validateRequestHostHeader()); @@ -318,6 +322,26 @@ static void validateHostHeader(HttpPrologue prologue, WritableHeaders headers } } + private void validatePrologue(HttpPrologue prologue) { + try { + // scheme is not validated, as it is fixed and validated by the prologue reader + UriValidator.validateQuery(prologue.query().rawValue()); + if (prologue.fragment().hasValue()) { + UriValidator.validateFragment(prologue.fragment().rawValue()); + } + } catch (IllegalArgumentException e) { + throw RequestException.builder() + .type(EventType.BAD_REQUEST) + .status(Status.BAD_REQUEST_400) + .request(DirectTransportRequest.create(prologue, ServerRequestHeaders.create())) + .setKeepAlive(false) + .message(e.getMessage()) + .safeMessage(true) + .cause(e) + .build(); + } + } + private static void simpleHostHeaderValidation(HttpPrologue prologue, WritableHeaders headers) { if (headers.contains(HeaderNames.HOST)) { String host = headers.get(HeaderNames.HOST).get(); @@ -384,17 +408,17 @@ private static void doValidateHostHeader(HttpPrologue prologue, WritableHeaders< int endLiteral = host.lastIndexOf(']'); if (startLiteral == 0 && endLiteral == host.length() - 1) { // this is most likely an IPv6 address without a port - HostValidator.validateIpLiteral(host); + UriValidator.validateIpLiteral(host); return; } if (startLiteral == 0 && endLiteral == -1) { - HostValidator.validateIpLiteral(host); + UriValidator.validateIpLiteral(host); return; } int colon = host.lastIndexOf(':'); if (colon == -1) { // only host - HostValidator.validateNonIpLiteral(host); + UriValidator.validateNonIpLiteral(host); return; } @@ -414,11 +438,11 @@ private static void doValidateHostHeader(HttpPrologue prologue, WritableHeaders< // can be // IP-literal [..::] if (startLiteral == 0 && endLiteral == hostString.length() - 1) { - HostValidator.validateIpLiteral(hostString); + UriValidator.validateIpLiteral(hostString); return; } - HostValidator.validateNonIpLiteral(hostString); + UriValidator.validateNonIpLiteral(hostString); } private BufferData readEntityFromPipeline(HttpPrologue prologue, WritableHeaders headers) { diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java index d817e044ada..d78090d29e7 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java @@ -114,7 +114,7 @@ void testBadPortSimpleValidation() { void testBadHosts() { // just empty invokeExpectFailure("Host header must not be empty", ""); - invokeExpectFailure("Invalid Host header: Host contains invalid character: int.the[.middle]", + invokeExpectFailure("Invalid Host header: Host contains invalid char: int.the[.middle], index: 7, char: '['", "int.the[.middle]:8080"); } @@ -122,7 +122,8 @@ void testBadHosts() { void testBadLiteral6() { // IPv6 // empty segment - invokeExpectFailure("Invalid Host header: Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", + invokeExpectFailure("Invalid Host header: " + + "Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", "[2001:db8::85a3::7334]"); } @@ -130,7 +131,7 @@ void testBadLiteral6() { void testBadLiteralFuture() { // IPv future // version must be present - invokeExpectFailure("Invalid Host header: Version cannot be blank: [v.abc:def]", + invokeExpectFailure("Invalid Host header: Version cannot be blank. Value: [v.abc:def]", "[v.abc:def]"); // missing address }