From 4e75e191c9226138a1d4cb0e6581e590449f76c8 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 10 Oct 2024 11:06:44 +0200 Subject: [PATCH] 4.x: Host header validation for server request (#9336) * bad host header tests. * Validate host header on server requests. * Added a host validator to HTTP module. Signed-off-by: Tomas Langer --- .../java/io/helidon/http/HostValidator.java | 347 ++++++++++++++++++ .../io/helidon/http/HostValidatorTest.java | 187 ++++++++++ microprofile/tests/server/pom.xml | 5 + .../tests/server/BadHostHeaderTest.java | 72 ++++ .../helidon/webserver/tests/BadHostTest.java | 69 ++++ .../webserver/tests/BadRequestTest.java | 6 +- .../webserver/http1/Http1ConfigBlueprint.java | 19 + .../webserver/http1/Http1Connection.java | 145 +++++++- .../http1/ValidateHostHeaderTest.java | 150 ++++++++ 9 files changed, 987 insertions(+), 13 deletions(-) create mode 100644 http/http/src/main/java/io/helidon/http/HostValidator.java create mode 100644 http/http/src/test/java/io/helidon/http/HostValidatorTest.java create mode 100644 microprofile/tests/server/src/test/java/io/helidon/microprofile/tests/server/BadHostHeaderTest.java create mode 100644 webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadHostTest.java create mode 100644 webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java 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); + } +}