diff --git a/http/http/src/main/java/io/helidon/http/HostValidator.java b/http/http/src/main/java/io/helidon/http/HostValidator.java
new file mode 100644
index 00000000000..834a27b0b5a
--- /dev/null
+++ b/http/http/src/main/java/io/helidon/http/HostValidator.java
@@ -0,0 +1,347 @@
+/*
+ * 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.http;
+
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Validate the host string (maybe from the {@code Host} header).
+ *
+ * Validation is based on
+ * RFC-3986.
+ */
+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() {
+ }
+
+ /**
+ * 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
+ */
+ public static void validate(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 java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded
+ */
+ 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));
+ }
+ }
+
+ /**
+ * 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
+ */
+ 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));
+ }
+ }
+}
diff --git a/http/http/src/test/java/io/helidon/http/HostValidatorTest.java b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java
new file mode 100644
index 00000000000..f0144f9e787
--- /dev/null
+++ b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.http;
+
+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;
+
+class HostValidatorTest {
+ @Test
+ void testGoodHostname() {
+ // sanity
+ validate("localhost");
+ // host names
+ validate("www.example.com");
+ // percent encoded
+ validate("%65%78%61%6D%70%6C%65");
+ validate("%65%78%61%6D%70%6C%65.com");
+ // with underscores
+ validate("www.exa_mple.com");
+ // with sub-delims
+ validate("www.exa$mple.com");
+ }
+
+ @Test
+ void testGoodIp4() {
+ // IPv4
+ 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]");
+ }
+
+ @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]");
+ }
+
+ @Test
+ void testGoodIpLiteralFuture() {
+ // IPvFuture
+ validate("[v9.abc:def]");
+ validate("[v9.abc:def*]");
+ }
+
+ @Test
+ void testBadHosts() {
+ // just empty
+ invokeExpectFailure("Host cannot be blank: ", "");
+ // invalid brackets
+ invokeExpectFailure("Host contains invalid character: [start.but.not.end",
+ "[start.but.not.end");
+ invokeExpectFailure("Host contains invalid character: end.but.not.start]",
+ "end.but.not.start]");
+ invokeExpectFailure("Host contains invalid character: int.the[.middle]",
+ "int.the[.middle]");
+ // invalid escape
+ invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%ZAxample.com",
+ "www.%ZAxample.com");
+ invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%AZxample.com",
+ "www.%AZxample.com");
+ // invalid character (non-ASCII
+ invokeExpectFailure("Host contains invalid character: www.čexample.com",
+ "www.čexample.com");
+ // wrong trailing escape (must be two chars);
+ invokeExpectFailure("Host contains invalid % encoding: www.example.com%4",
+ "www.example.com%4");
+ invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%č4",
+ "www.example.com%č4");
+ invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%4č",
+ "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 non hexadecimal character: "
+ + "[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]",
+ "[č: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]",
+ "[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: []",
+ "[]");
+ // wrong start (leading colon)
+ invokeExpectFailure("Host IPv6 contains excessive colon: [:1:0::]",
+ "[:1:0::]");
+ // wrong end, colon instead of value
+ invokeExpectFailure("IPv6 segment non hexadecimal character: [1:0:::]",
+ "[1:0:::]");
+
+ invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::",
+ "[::");
+ invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::]",
+ "::]");
+ }
+
+ @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: [v.abc:def]",
+ "[v.abc:def]");
+ // missing address
+ invokeExpectFailure("IP Future must contain 'v.': [v2]",
+ "[v2]");
+ invokeExpectFailure("IP Future cannot be blank: [v2.]",
+ "[v2.]");
+ // invalid character in the host (valid future)
+ invokeExpectFailure("Host contains invalid character: [v2./0:::]",
+ "[v2./0:::]");
+ invokeExpectFailure("Host contains invalid character: [v2.0:č]",
+ "[v2.0:č]");
+ }
+
+ private static void invokeExpectFailure(String message, String host) {
+ var t = assertThrows(IllegalArgumentException.class, () -> 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);
+ assertThat(t.getMessage(), is(message));
+ }
+}
\ No newline at end of file
diff --git a/microprofile/tests/server/pom.xml b/microprofile/tests/server/pom.xml
index 2c62df13013..cf53de07494 100644
--- a/microprofile/tests/server/pom.xml
+++ b/microprofile/tests/server/pom.xml
@@ -65,5 +65,10 @@
helidon-logging-jul
test
+
+ io.helidon.webclient
+ helidon-webclient-http1
+ test
+
diff --git a/microprofile/tests/server/src/test/java/io/helidon/microprofile/tests/server/BadHostHeaderTest.java b/microprofile/tests/server/src/test/java/io/helidon/microprofile/tests/server/BadHostHeaderTest.java
new file mode 100644
index 00000000000..1efb71706b2
--- /dev/null
+++ b/microprofile/tests/server/src/test/java/io/helidon/microprofile/tests/server/BadHostHeaderTest.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2021, 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.microprofile.tests.server;
+
+import io.helidon.http.Header;
+import io.helidon.http.HeaderValues;
+import io.helidon.http.Status;
+import io.helidon.microprofile.testing.junit5.AddBean;
+import io.helidon.microprofile.testing.junit5.HelidonTest;
+import io.helidon.webclient.api.WebClient;
+import io.helidon.webserver.http.ServerRequest;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@HelidonTest
+@AddBean(BadHostHeaderTest.TestResource.class)
+public class BadHostHeaderTest {
+ private static final Header BAD_HOST_HEADER = HeaderValues.create("Host", "localhost:808a");
+
+ @Test
+ void testGetGoodHeader(WebTarget target) {
+ String getResponse = target.path("/get").request().get(String.class);
+ assertThat(getResponse, is("localhost"));
+ }
+
+ @Test
+ void testGetBadHeader(WebTarget target) {
+ WebClient webClient = WebClient.builder()
+ .baseUri(target.getUri())
+ .build();
+ var response = webClient.get("/get")
+ .header(BAD_HOST_HEADER)
+ .request(String.class);
+ assertThat(response.status(), is(Status.BAD_REQUEST_400));
+ assertThat(response.entity(), is("Invalid port of the host header: 808a"));
+ }
+
+ @Path("/")
+ public static class TestResource {
+ @Context ServerRequest request;
+
+ @GET
+ @Path("get")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getIt() {
+ return request.requestedUri().host();
+ }
+ }
+}
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadHostTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadHostTest.java
new file mode 100644
index 00000000000..99fccc4df27
--- /dev/null
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadHostTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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 io.helidon.http.Header;
+import io.helidon.http.HeaderValues;
+import io.helidon.http.Method;
+import io.helidon.http.Status;
+import io.helidon.webclient.api.ClientResponseTyped;
+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.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+@ServerTest
+class BadHostTest {
+ private static final Header BAD_HOST_HEADER = HeaderValues.create("Host", "localhost:808a");
+
+ private final Http1Client client;
+
+ BadHostTest(Http1Client client) {
+ this.client = client;
+ }
+
+ @SetUpRoute
+ static void routing(HttpRouting.Builder builder) {
+ builder.route(Http1Route.route(Method.GET,
+ "/",
+ (req, res) -> res.send(req.requestedUri().host())));
+ }
+
+ @Test
+ void testOk() {
+ String response = client.method(Method.GET)
+ .requestEntity(String.class);
+
+ assertThat(response, is("localhost"));
+ }
+
+ @Test
+ void testInvalidRequest() {
+ ClientResponseTyped response = client.method(Method.GET)
+ .header(BAD_HOST_HEADER)
+ .request(String.class);
+
+ assertThat(response.status(), is(Status.BAD_REQUEST_400));
+ assertThat(response.entity(), is("Invalid port of the host header: 808a"));
+ }
+}
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadRequestTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadRequestTest.java
index 59237c6183d..a18675ba4db 100644
--- a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadRequestTest.java
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadRequestTest.java
@@ -43,6 +43,7 @@
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
+
@ServerTest
class BadRequestTest {
public static final String CUSTOM_REASON_PHRASE = "Custom-bad-request";
@@ -71,13 +72,10 @@ static void setUpServer(WebServerConfig.Builder builder) {
.build());
}
- // no need to try with resources when reading as string
- @SuppressWarnings("resource")
@Test
void testOk() {
String response = client.method(Method.GET)
- .request()
- .as(String.class);
+ .requestEntity(String.class);
assertThat(response, is("Hi"));
}
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 2f35ecbe445..dc8a93daf42 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
@@ -73,6 +73,25 @@ interface Http1ConfigBlueprint extends ProtocolConfig {
@Option.DefaultBoolean(true)
boolean validateRequestHeaders();
+ /**
+ * 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 HTTP specification.
+ *
+ * This option allows you to disable the "full-blown" validation ("simple" validation is still in - the port must be
+ * parseable to integer).
+ *
+ * @return whether to do a full validation of {@code Host} header according to the specification
+ * @deprecated this switch exists for temporary backward compatible behavior, and will be removed in a future Helidon
+ * version
+ */
+ @Option.Configured
+ @Option.DefaultBoolean(true)
+ @Deprecated(forRemoval = true, since = "4.1.3")
+ boolean validateRequestHostHeader();
+
/**
* Whether to validate headers.
* If set to false, any value is accepted, otherwise validates headers + known headers
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 ed0c5f0f5ee..6b6e6aa9ae3 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
@@ -20,6 +20,7 @@
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.ZonedDateTime;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
@@ -38,6 +39,8 @@
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;
import io.helidon.http.RequestException;
@@ -66,15 +69,13 @@
* HTTP/1.1 server connection.
*/
public class Http1Connection implements ServerConnection, InterruptableTask {
+ static final byte[] CONTINUE_100 = "HTTP/1.1 100 Continue\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final System.Logger LOGGER = System.getLogger(Http1Connection.class.getName());
private static final Supplier INVALID_SIZE_EXCEPTION_SUPPLIER =
() -> RequestException.builder()
.type(EventType.BAD_REQUEST)
.message("Chunk size is invalid")
.build();
-
- static final byte[] CONTINUE_100 = "HTTP/1.1 100 Continue\r\n\r\n".getBytes(StandardCharsets.UTF_8);
-
private final ConnectionContext ctx;
private final Http1Config http1Config;
private final DataWriter writer;
@@ -104,7 +105,7 @@ public class Http1Connection implements ServerConnection, InterruptableTask headers = http1headers.readHeaders(prologue);
+ if (http1Config.validateRequestHeaders()) {
+ validateHostHeader(prologue, headers, http1Config.validateRequestHostHeader());
+ }
ctx.remotePeer().tlsCertificates()
.flatMap(TlsUtils::parseCn)
.ifPresent(name -> headers.set(X_HELIDON_CN, name));
@@ -258,6 +263,133 @@ public void close(boolean interrupt) {
}
}
+ void reset() {
+ currentEntitySize = 0;
+ currentEntitySizeRead = 0;
+ }
+
+ static void validateHostHeader(HttpPrologue prologue, WritableHeaders> headers, boolean fullValidation) {
+ if (fullValidation) {
+ try {
+ doValidateHostHeader(prologue, headers);
+ } catch (IllegalArgumentException e) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Invalid Host header: " + e.getMessage())
+ .cause(e)
+ .build();
+ }
+ } else {
+ simpleHostHeaderValidation(prologue, headers);
+ }
+ }
+
+ private static void simpleHostHeaderValidation(HttpPrologue prologue, WritableHeaders> headers) {
+ if (headers.contains(HeaderNames.HOST)) {
+ String host = headers.get(HeaderNames.HOST).get();
+ // this is what is used to set up URI information, and this MUST work
+ int index = host.lastIndexOf(':');
+ if (index < 1) {
+ return;
+ }
+ // this may still be an IPv6 address
+ if (host.charAt(host.length() - 1) == ']') {
+ // IP literal without port
+ return;
+ }
+
+ try {
+ // port must be parseable to int
+ Integer.parseInt(host.substring(index + 1));
+ } catch (NumberFormatException e) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Invalid port of the host header: " + HtmlEncoder.encode(host.substring(index + 1)))
+ .build();
+ }
+
+ }
+
+ }
+
+ private static void doValidateHostHeader(HttpPrologue prologue, WritableHeaders> headers) {
+ List hostHeaders = headers.all(HeaderNames.HOST, List::of);
+ if (hostHeaders.isEmpty()) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Host header must be present in the request")
+ .build();
+ }
+ if (hostHeaders.size() > 1) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Only a single Host header is allowed in request")
+ .build();
+ }
+ String host = hostHeaders.getFirst();
+ if (host.isEmpty()) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Host header must not be empty")
+ .build();
+ }
+ // now host and port must be valid
+ int startLiteral = host.indexOf('[');
+ int endLiteral = host.lastIndexOf(']');
+ if (startLiteral == 0 && endLiteral == host.length() - 1) {
+ // this is most likely an IPv6 address without a port
+ HostValidator.validateIpLiteral(host);
+ return;
+ }
+ if (startLiteral == 0 && endLiteral == -1) {
+ HostValidator.validateIpLiteral(host);
+ return;
+ }
+ int colon = host.lastIndexOf(':');
+ if (colon == -1) {
+ // only host
+ HostValidator.validateNonIpLiteral(host);
+ return;
+ }
+
+ String portString = host.substring(colon + 1);
+ try {
+ Integer.parseInt(portString);
+ } catch (NumberFormatException e) {
+ throw RequestException.builder()
+ .type(EventType.BAD_REQUEST)
+ .status(Status.BAD_REQUEST_400)
+ .request(DirectTransportRequest.create(prologue, headers))
+ .setKeepAlive(false)
+ .message("Invalid port of the host header: " + HtmlEncoder.encode(portString))
+ .build();
+ }
+ String hostString = host.substring(0, colon);
+ // can be
+ // IP-literal [..::]
+ if (startLiteral == 0 && endLiteral == hostString.length() - 1) {
+ HostValidator.validateIpLiteral(hostString);
+ return;
+ }
+
+ HostValidator.validateNonIpLiteral(hostString);
+ }
+
private BufferData readEntityFromPipeline(HttpPrologue prologue, WritableHeaders> headers) {
if (currentEntitySize == -1) {
// chunked
@@ -495,9 +627,4 @@ private void handleRequestException(RequestException e) {
LOGGER.log(WARNING, "Internal server error", e);
}
}
-
- void reset() {
- currentEntitySize = 0;
- currentEntitySizeRead = 0;
- }
}
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
new file mode 100644
index 00000000000..d817e044ada
--- /dev/null
+++ b/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java
@@ -0,0 +1,150 @@
+/*
+ * 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.http1;
+
+import java.util.Arrays;
+
+import io.helidon.http.HeaderNames;
+import io.helidon.http.HttpPrologue;
+import io.helidon.http.Method;
+import io.helidon.http.RequestException;
+import io.helidon.http.WritableHeaders;
+
+import org.junit.jupiter.api.Test;
+
+import static io.helidon.webserver.http1.Http1Connection.validateHostHeader;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class ValidateHostHeaderTest {
+ private static final HttpPrologue TEST_PROLOGUE =
+ HttpPrologue.create("http", "http", "1.1", Method.GET, "/", false);
+
+ @Test
+ void testNone() {
+ invokeExpectFailure("Host header must be present in the request");
+ }
+
+ @Test
+ void testMany() {
+ invokeExpectFailure("Only a single Host header is allowed in request", "first", "second");
+ }
+
+ @Test
+ void testGoodHostname() {
+ // sanity
+ invoke("localhost");
+ invoke("localhost:8080");
+ // host names
+ invoke("www.example.com:445");
+ // percent encoded
+ invoke("%65%78%61%6D%70%6C%65:8080");
+ invoke("%65%78%61%6D%70%6C%65.com:8080");
+ // with underscores
+ invoke("www.exa_mple.com:8080");
+ }
+
+ @Test
+ void testGoodIp4() {
+ // IPv4
+ invoke("192.167.1.1");
+ invoke("192.167.1.1:8080");
+ }
+
+ @Test
+ void testGoodIpLiteral6() {
+ // IPv6
+ invoke("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]");
+ invoke("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]:8080");
+ invoke("[::1]");
+ invoke("[::1]:8080");
+ }
+
+ @Test
+ void testGoodIpLiteral6Dual() {
+ // IPv6
+ invoke("[2001:db8:3333:4444:5555:6666:1.2.3.4]:8080");
+ invoke("[::11.22.33.44]");
+ }
+
+ @Test
+ void testGoodIpLiteralFuture() {
+ // IPvFuture
+ invoke("[v9.abc:def]");
+ invoke("[v9.abc:def]:8080");
+ }
+
+ @Test
+ void testBadPort() {
+ // unparsable port
+ invokeExpectFailure("Invalid port of the host header: 80a", "192.167.1.1:80a");
+ invokeExpectFailure("Invalid port of the host header: 80_80", "localhost:80_80");
+ }
+
+ @Test
+ void testBadPortSimpleValidation() {
+ // these must fail even when validation is disabled
+ WritableHeaders> headers = WritableHeaders.create();
+ headers.set(HeaderNames.HOST, "192.167.1.1:80a");
+ var t = assertThrows(RequestException.class, () -> validateHostHeader(TEST_PROLOGUE, headers, false));
+ assertThat(t.getMessage(), is("Invalid port of the host header: 80a"));
+
+ headers.set(HeaderNames.HOST, "192.167.1.1:80_80");
+ t = assertThrows(RequestException.class, () -> validateHostHeader(TEST_PROLOGUE, headers, false));
+ assertThat(t.getMessage(), is("Invalid port of the host header: 80_80"));
+ }
+
+
+ @Test
+ void testBadHosts() {
+ // just empty
+ invokeExpectFailure("Host header must not be empty", "");
+ invokeExpectFailure("Invalid Host header: Host contains invalid character: int.the[.middle]",
+ "int.the[.middle]:8080");
+ }
+
+ @Test
+ void testBadLiteral6() {
+ // IPv6
+ // empty segment
+ invokeExpectFailure("Invalid Host header: Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]",
+ "[2001:db8::85a3::7334]");
+ }
+
+ @Test
+ void testBadLiteralFuture() {
+ // IPv future
+ // version must be present
+ invokeExpectFailure("Invalid Host header: Version cannot be blank: [v.abc:def]",
+ "[v.abc:def]");
+ // missing address
+ }
+
+ private static void invokeExpectFailure(String message, String... hosts) {
+ var t = assertThrows(RequestException.class, () -> invoke(hosts), "Testing hosts: " + Arrays.toString(hosts));
+ assertThat(t.getMessage(), is(message));
+ }
+
+ private static void invoke(String... values) {
+ WritableHeaders> headers = WritableHeaders.create();
+ if (values.length > 0) {
+ headers.set(HeaderNames.HOST, values);
+ }
+ validateHostHeader(TEST_PROLOGUE, headers, true);
+ }
+}