diff --git a/docs/mp/jaxrs/02_server-configuration.adoc b/docs/mp/jaxrs/02_server-configuration.adoc index 3f3a48677a4..c0ebac3d864 100644 --- a/docs/mp/jaxrs/02_server-configuration.adoc +++ b/docs/mp/jaxrs/02_server-configuration.adoc @@ -45,18 +45,16 @@ server.port=7011 # Helidon configuration (optional) # Length of queue for incoming connections. Default is 1024 -server.backlog: 512 +server.backlog=512 # TCP receive window. Default is 0 to use implementation default -server.receive-buffer: 256 +server.receive-buffer=256 # Socket timeout milliseconds - defaults to 0 (infinite) -server.timeout: 30000 +server.timeout=30000 # Defaults to Runtime.availableProcessors() server.workers=4 # Default is not to use SSL -ssl: - private-key: - keystore-resource-path: "certificate.p12" - keystore-passphrase: "abcd" +ssl.private-key.keystore-resource-path=certificate.p12 +ssl.private-key.keystore-passphrase="abcd" ---- == Configuring additional ports diff --git a/docs/se/webserver/02_configuration.adoc b/docs/se/webserver/02_configuration.adoc index 369bca07a47..9149eab64a0 100644 --- a/docs/se/webserver/02_configuration.adoc +++ b/docs/se/webserver/02_configuration.adoc @@ -67,3 +67,24 @@ just use `Config.create()` See all configuration options link:{javadoc-base-url-api}/WebServer.html[here]. + +Available socket configuration options: + +[cols="^2s,<2,<2,<6"] +|=== +|Configuration key |Default value ^|Java type ^|Description + +|`port` |{nbsp} |int |Port to open server socket on, defaults to an available ephemeral port +|`bind-address` |all local addresses |String |Address to listen on (may be an IPV6 address as well) +|`backlog` |`1024` |int |Maximum length of the queue of incoming connections on the server socket. +|`max-header-size` |`8192` |int |Maximal number of bytes of all header values combined. Returns `400` if headers are bigger +|`max-initial-line-length` |`4096` |int |Maximal number of characters in the initial HTTP line. Returns `400` if line is longer +|`timeout-millis` |no timeout| long |Server socket timeout. +|`receive-buffer-size` |implementation default |int |Proposed value of the TCP receive window that is advertised to the remote peer on the server socket. +|`name` |`@default` for default socket |String |Name used for named sockets, to support additional server sockets (and their named routing) +|`enabled` |`true` |boolean |A socket can be disabled through configuration, in which case it is never opened +|`max-chunk-size` | `8192` |int |Maximal size of a chunk to read from incoming requests +|`validate-headers` |`true` |boolean |Whether to validate header names, if they contain illegal characters. +|`initial-buffer-size` |`128` |int |Initial size of buffer used to parse HTTP line and headers +|`tls` |{nbsp} |Object |Configuration of SSL, please see our SSL example in repository +|=== \ No newline at end of file diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/WebClient.java b/webclient/webclient/src/main/java/io/helidon/webclient/WebClient.java index 67eef54730f..296cbf2ba14 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/WebClient.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/WebClient.java @@ -388,6 +388,18 @@ public Builder keepAlive(boolean keepAlive) { return this; } + /** + * Whether to validate header names. + * Defaults to {@code true}. + * + * @param validate whether to validate the header name contains only allowed characters + * @return updated builder instance + */ + public Builder validateHeaders(boolean validate) { + configuration.validateHeaders(validate); + return this; + } + WebClientConfiguration configuration() { configuration.clientServices(services()); return configuration.build(); diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientConfiguration.java b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientConfiguration.java index aa365f3d2bc..b64d2bd30de 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientConfiguration.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientConfiguration.java @@ -79,6 +79,7 @@ class WebClientConfiguration { private final MessageBodyWriterContext writerContext; private final WebClientTls webClientTls; private final URI uri; + private final boolean validateHeaders; /** * Creates a new instance of client configuration. @@ -107,6 +108,7 @@ class WebClientConfiguration { this.clientServices = Collections.unmodifiableList(builder.clientServices); this.uri = builder.uri; this.keepAlive = builder.keepAlive; + this.validateHeaders = builder.validateHeaders; } /** @@ -267,6 +269,10 @@ boolean keepAlive() { return keepAlive; } + boolean validateHeaders() { + return validateHeaders; + } + /** * A fluent API builder for {@link WebClientConfiguration}. */ @@ -295,6 +301,7 @@ static class Builder, T extends WebClientConfiguration> private MessageBodyReaderContext readerContext; private MessageBodyWriterContext writerContext; private List clientServices; + private boolean validateHeaders = true; @SuppressWarnings("unchecked") private B me = (B) this; @@ -486,6 +493,18 @@ public B uri(URI uri) { return me; } + /** + * Whether to validate header names. + * Defaults to {@code true}. + * + * @param validate whether to validate the header name contains only allowed characters + * @return updated builder instance + */ + B validateHeaders(boolean validate) { + this.validateHeaders = validate; + return me; + } + @Override public B addReader(MessageBodyReader reader) { this.readerContext.registerReader(reader); diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java index f76c2fd2a42..ba09777abfc 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java @@ -657,7 +657,7 @@ private HttpVersion toNettyHttpVersion(Http.Version version) { } private HttpHeaders toNettyHttpHeaders() { - HttpHeaders headers = new DefaultHttpHeaders(); + HttpHeaders headers = new DefaultHttpHeaders(this.configuration.validateHeaders()); try { Map> cookieHeaders = this.configuration.cookieManager().get(uri, new HashMap<>()); List cookies = new ArrayList<>(cookieHeaders.get(Http.Header.COOKIE)); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java b/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java index 189ad6688ad..222181a056d 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2020 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2017, 2020 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. @@ -57,10 +57,15 @@ class HttpInitializer extends ChannelInitializer { private final SslContext sslContext; private final NettyWebServer webServer; + private final SocketConfiguration soConfig; private final Routing routing; private final Queue> queues = new ConcurrentLinkedQueue<>(); - HttpInitializer(SslContext sslContext, Routing routing, NettyWebServer webServer) { + HttpInitializer(SocketConfiguration soConfig, + SslContext sslContext, + Routing routing, + NettyWebServer webServer) { + this.soConfig = soConfig; this.routing = routing; this.sslContext = sslContext; this.webServer = webServer; @@ -91,7 +96,11 @@ public void initChannel(SocketChannel ch) { // Set up HTTP/2 pipeline if feature is enabled ServerConfiguration serverConfig = webServer.configuration(); - HttpRequestDecoder requestDecoder = new HttpRequestDecoder(); + HttpRequestDecoder requestDecoder = new HttpRequestDecoder(soConfig.maxInitialLineLength(), + soConfig.maxHeaderSize(), + 8192, + soConfig.validateHeaders(), + soConfig.initialBufferSize()); if (serverConfig.isHttp2Enabled()) { ExperimentalConfiguration experimental = serverConfig.experimental(); Http2Configuration http2Config = experimental.http2(); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/NettyWebServer.java b/webserver/webserver/src/main/java/io/helidon/webserver/NettyWebServer.java index 7d34991592e..ad752d53dcc 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/NettyWebServer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/NettyWebServer.java @@ -162,7 +162,10 @@ class NettyWebServer implements WebServer { bootstrap.option(ChannelOption.SO_RCVBUF, soConfig.receiveBufferSize()); } - HttpInitializer childHandler = new HttpInitializer(sslContext, namedRoutings.getOrDefault(name, routing), this); + HttpInitializer childHandler = new HttpInitializer(soConfig, + sslContext, + namedRoutings.getOrDefault(name, routing), + this); initializers.add(childHandler); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java index 0d4cd58fe98..d2d400e902a 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java @@ -104,6 +104,31 @@ public int receiveBufferSize() { return socketConfig.receiveBufferSize(); } + @Override + public int maxHeaderSize() { + return socketConfig.maxHeaderSize(); + } + + @Override + public int maxInitialLineLength() { + return socketConfig.maxInitialLineLength(); + } + + @Override + public int maxChunkSize() { + return socketConfig.maxChunkSize(); + } + + @Override + public boolean validateHeaders() { + return socketConfig.validateHeaders(); + } + + @Override + public int initialBufferSize() { + return socketConfig.initialBufferSize(); + } + @Override public Tracer tracer() { return tracer; @@ -141,6 +166,11 @@ static class SocketConfig implements SocketConfiguration { private final String name; private final boolean enabled; private final ClientAuthentication clientAuth; + private final int maxHeaderSize; + private final int maxInitialLineLength; + private final int maxChunkSize; + private final boolean validateHeaders; + private final int initialBufferSize; /** * Creates new instance. @@ -153,6 +183,12 @@ static class SocketConfig implements SocketConfiguration { this.backlog = builder.backlog() < 0 ? DEFAULT_BACKLOG_SIZE : builder.backlog(); this.timeoutMillis = Math.max(builder.timeoutMillis(), 0); this.receiveBufferSize = Math.max(builder.receiveBufferSize(), 0); + this.maxHeaderSize = builder.maxHeaderSize(); + this.maxInitialLineLength = builder.maxInitialLineLength(); + this.maxChunkSize = builder.maxChunkSize(); + this.validateHeaders = builder.validateHeaders(); + this.initialBufferSize = builder.initialBufferSize(); + WebServerTls webServerTls = builder.tlsConfig(); if (webServerTls.enabled()) { this.sslContext = webServerTls.sslContext(); @@ -214,5 +250,30 @@ public String name() { public boolean enabled() { return enabled; } + + @Override + public int maxHeaderSize() { + return maxHeaderSize; + } + + @Override + public int maxInitialLineLength() { + return maxInitialLineLength; + } + + @Override + public int maxChunkSize() { + return maxChunkSize; + } + + @Override + public boolean validateHeaders() { + return validateHeaders; + } + + @Override + public int initialBufferSize() { + return initialBufferSize; + } } } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java index 394308cedd9..8dd6f60f8a6 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java @@ -362,6 +362,18 @@ public Builder receiveBufferSize(int bytes) { return this; } + @Override + public Builder maxHeaderSize(int size) { + defaultSocketBuilder.maxHeaderSize(size); + return this; + } + + @Override + public Builder maxInitialLineLength(int length) { + defaultSocketBuilder.maxInitialLineLength(length); + return this; + } + /** * Adds an additional named server socket configuration. As a result, the server will listen * on multiple ports. diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java index 040c3489a13..4292179ab57 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java @@ -135,6 +135,42 @@ default boolean enabled() { return true; } + /** + * Maximal size of all headers combined. + * + * @return size in bytes + */ + int maxHeaderSize(); + + /** + * Maximal length of the initial HTTP line. + * + * @return length + */ + int maxInitialLineLength(); + + /** + * Maximal size of a single chunk of received data. + * + * @return chunk size + */ + int maxChunkSize(); + + /** + * Whether to validate HTTP header names. + * When set to {@code true}, we make sure the header name is a valid string + * + * @return {@code true} if headers should be validated + */ + boolean validateHeaders(); + + /** + * Initial size of the buffer used to parse HTTP line and headers. + * + * @return initial size of the buffer + */ + int initialBufferSize(); + /** * Creates a builder of {@link SocketConfiguration} class. * @@ -258,6 +294,28 @@ default B tls(Supplier tlsConfig) { return tls(tlsConfig.get()); } + /** + * Maximal number of bytes of all header values combined. When a bigger value is received, a + * {@link io.helidon.common.http.Http.Status#BAD_REQUEST_400} + * is returned. + *

+ * Default is {@code 8192} + * + * @param size maximal number of bytes of combined header values + * @return this builder + */ + B maxHeaderSize(int size); + + /** + * Maximal number of characters in the initial HTTP line. + *

+ * Default is {@code 4096} + * + * @param length maximal number of characters + * @return this builder + */ + B maxInitialLineLength(int length); + /** * Update this socket configuration from a {@link io.helidon.config.Config}. * @@ -269,6 +327,8 @@ default B config(Config config) { config.get("port").asInt().ifPresent(this::port); config.get("bind-address").asString().ifPresent(this::host); config.get("backlog").asInt().ifPresent(this::backlog); + config.get("max-header-size").asInt().ifPresent(this::maxHeaderSize); + config.get("max-initial-line-length").asInt().ifPresent(this::maxInitialLineLength); DeprecatedConfig.get(config, "timeout-millis", "timeout") .asInt() @@ -319,6 +379,12 @@ final class Builder implements SocketConfigurationBuilder, io.helidon.c // methods with `name` are removed from server builder (for adding sockets) private String name = UNCONFIGURED_NAME; private boolean enabled = true; + // these values are as defined in Netty implementation + private int maxHeaderSize = 8192; + private int maxInitialLineLength = 4096; + private int maxChunkSize = 8192; + private boolean validateHeaders = true; + private int initialBufferSize = 128; private Builder() { } @@ -474,6 +540,18 @@ public Builder tls(WebServerTls webServerTls) { return this; } + @Override + public Builder maxHeaderSize(int size) { + this.maxHeaderSize = size; + return this; + } + + @Override + public Builder maxInitialLineLength(int length) { + this.maxInitialLineLength = length; + return this; + } + /** * Configure a socket name, to bind named routings to. * @@ -496,12 +574,51 @@ public Builder enabled(boolean enabled) { return this; } + /** + * Configure maximal size of a chunk to be read from incoming requests. + * Defaults to {@code 8192}. + * + * @param size maximal chunk size + * @return updated builder instance + */ + public Builder maxChunkSize(int size) { + this.maxChunkSize = size; + return this; + } + + /** + * Configure whether to validate header names. + * Defaults to {@code true} to make sure header names are valid strings. + * + * @param validate set to {@code false} to ignore header validation + * @return updated builder instance + */ + public Builder validateHeaders(boolean validate) { + this.validateHeaders = validate; + return this; + } + + /** + * Configure initial size of the buffer used to parse HTTP line and headers. + * Defaults to {@code 128}. + * + * @param size initial buffer size + * @return updated builder instance + */ + public Builder initialBufferSize(int size) { + this.initialBufferSize = size; + return this; + } + @Override public Builder config(Config config) { SocketConfigurationBuilder.super.config(config); config.get("name").asString().ifPresent(this::name); config.get("enabled").asBoolean().ifPresent(this::enabled); + config.get("max-chunk-size").asInt().ifPresent(this::maxChunkSize); + config.get("validate-headers").asBoolean().ifPresent(this::validateHeaders); + config.get("initial-buffer-size").asInt().ifPresent(this::initialBufferSize); return this; } @@ -530,20 +647,32 @@ WebServerTls tlsConfig() { return webServerTls; } - private static InetAddress string2InetAddress(String address) { - try { - return InetAddress.getByName(address); - } catch (UnknownHostException e) { - throw new ConfigException("Illegal value of 'bind-address' configuration key. Expecting host or ip address!", e); - } - } - String name() { return name; } - public boolean enabled() { + boolean enabled() { return enabled; } + + int maxHeaderSize() { + return maxHeaderSize; + } + + int maxInitialLineLength() { + return maxInitialLineLength; + } + + int maxChunkSize() { + return maxChunkSize; + } + + boolean validateHeaders() { + return validateHeaders; + } + + int initialBufferSize() { + return initialBufferSize; + } } } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java index 061037ad5a2..a949f6a27e7 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java @@ -557,6 +557,18 @@ public Builder tls(WebServerTls webServerTls) { return this; } + @Override + public Builder maxHeaderSize(int size) { + configurationBuilder.maxHeaderSize(size); + return this; + } + + @Override + public Builder maxInitialLineLength(int length) { + configurationBuilder.maxInitialLineLength(length); + return this; + } + /** * Configure experimental features. * @param experimental experimental configuration diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/TestHttpParseFineTuning.java b/webserver/webserver/src/test/java/io/helidon/webserver/TestHttpParseFineTuning.java new file mode 100644 index 00000000000..2e688731e26 --- /dev/null +++ b/webserver/webserver/src/test/java/io/helidon/webserver/TestHttpParseFineTuning.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2020 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; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.helidon.common.http.Http; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.webclient.WebClient; +import io.helidon.webclient.WebClientRequestBuilder; +import io.helidon.webclient.WebClientResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +class TestHttpParseFineTuning { + + @Test + void testDefaults() { + // default is 8Kb for headers + // and 4096 for initial line + WebServer ws = WebServer.builder() + .routing(Routing.builder() + .register("/static", StaticContentSupport.create("/static")) + .any((req, res) -> res.send("any")) + .build()) + .build() + .start() + .await(10, TimeUnit.SECONDS); + + WebClient client = WebClient.builder() + .baseUri("http://localhost:" + ws.port()) + .validateHeaders(false) + .build(); + + testHeader(client, 8000, true); + testInitialLine(client, 10, true); + + testHeader(client, 8900, false); + testHeader(client, 8900, false); + + // now test with big initial line + testInitialLine(client, 5000, false); + + testHeaderName(client, "X_HEADER", true); + testHeaderName(client, "X\tHEADER", false); + } + + @Test + void testCustom() { + Config config = Config.create(ConfigSources.create(Map.of("validate-headers", "false"))); + + WebServer ws = WebServer.builder() + .routing(Routing.builder() + .register("/static", StaticContentSupport.create("/static")) + .any((req, res) -> res.send("any")) + .build()) + .config(config) + .maxHeaderSize(9100) + .maxInitialLineLength(5100) + .build() + .start() + .await(10, TimeUnit.SECONDS); + + WebClient client = WebClient.builder() + .baseUri("http://localhost:" + ws.port()) + .validateHeaders(false) + .build(); + + testHeader(client, 8000, true); + testInitialLine(client, 10, true); + + testHeader(client, 8900, true); + testHeader(client, 8900, true); + + // now test with big initial line + testInitialLine(client, 5000, true); + + testHeaderName(client, "X_HEADER", true); + testHeaderName(client, "X\tHEADER", true); + } + + private void testHeaderName(WebClient client, String headerName, boolean success) { + WebClientRequestBuilder builder = client.get(); + builder.headers().add(headerName, "some random value"); + WebClientResponse response = builder.path("/static/static-content.txt") + .request() + .await(10, TimeUnit.SECONDS); + + if (success) { + assertThat("Header '" + headerName + "' should have passed", response.status(), is(Http.Status.OK_200)); + assertThat("This request should return content of static-content.txt", response.content() + .as(String.class) + .await(10, TimeUnit.SECONDS), + is("Hi")); + } else { + assertThat("Header '" + headerName + "' should have failed", response.status(), is(Http.Status.BAD_REQUEST_400)); + } + } + + private void testInitialLine(WebClient client, int size, boolean success) { + String line = longString(size); + WebClientResponse response = client.get() + .path("/long/" + line) + .request() + .await(10, TimeUnit.SECONDS); + + if (success) { + assertThat("Initial line of size " + size + " should have passed", response.status(), is(Http.Status.OK_200)); + assertThat("This request should return what is configured in routing", response.content() + .as(String.class) + .await(10, TimeUnit.SECONDS), + is("any")); + } else { + assertThat("Initial line of size " + size + " should have failed", + response.status(), + is(Http.Status.BAD_REQUEST_400)); + } + } + + private void testHeader(WebClient client, int size, boolean success) { + String headerValue = longString(size); + WebClientRequestBuilder builder = client.get(); + builder.headers().add("X_HEADER", headerValue); + WebClientResponse response = builder.path("/static/static-content.txt") + .request() + .await(10, TimeUnit.SECONDS); + + if (success) { + assertThat("Header of size " + size + " should have passed", response.status(), is(Http.Status.OK_200)); + assertThat("This request should return content of static-content.txt", response.content() + .as(String.class) + .await(10, TimeUnit.SECONDS), + is("Hi")); + } else { + assertThat("Header of size " + size + " should have failed", response.status(), is(Http.Status.BAD_REQUEST_400)); + } + } + + private String longString(int size) { + return "a".repeat(Math.max(0, size)); + } +} diff --git a/webserver/webserver/src/test/resources/static/static-content.txt b/webserver/webserver/src/test/resources/static/static-content.txt new file mode 100644 index 00000000000..40816a2b5a9 --- /dev/null +++ b/webserver/webserver/src/test/resources/static/static-content.txt @@ -0,0 +1 @@ +Hi \ No newline at end of file