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: + *

+ */ +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 }