Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for a max payload limit on client requests #2491

Merged
merged 6 commits into from
Nov 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/se/webserver/02_configuration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Available socket configuration options:
|`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
|`max-payload-size` | `-1` |long |Maximal size of a request payload in bytes. If exceeded a 413 error is returned. Negative value means no limit.
|`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 TLS, please see our TLS example in repository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import static io.helidon.webserver.HttpInitializer.CERTIFICATE_NAME;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static io.netty.handler.codec.http.HttpResponseStatus.CONTINUE;
import static io.netty.handler.codec.http.HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;

/**
Expand All @@ -68,23 +69,34 @@ public class ForwardingHandler extends SimpleChannelInboundHandler<Object> {
private final SSLEngine sslEngine;
private final Queue<ReferenceHoldingQueue<DataChunk>> queues;
private final HttpRequestDecoder httpRequestDecoder;
private final long maxPayloadSize;

// this field is always accessed by the very same thread; as such, it doesn't need to be
// concurrency aware
private RequestContext requestContext;

private boolean isWebSocketUpgrade = false;
private boolean isWebSocketUpgrade;
private long actualPayloadSize;
private boolean ignorePayload;

ForwardingHandler(Routing routing,
NettyWebServer webServer,
SSLEngine sslEngine,
Queue<ReferenceHoldingQueue<DataChunk>> queues,
HttpRequestDecoder httpRequestDecoder) {
HttpRequestDecoder httpRequestDecoder,
long maxPayloadSize) {
this.routing = routing;
this.webServer = webServer;
this.sslEngine = sslEngine;
this.queues = queues;
this.httpRequestDecoder = httpRequestDecoder;
this.maxPayloadSize = maxPayloadSize;
}

private void reset() {
isWebSocketUpgrade = false;
actualPayloadSize = 0L;
ignorePayload = false;
}

@Override
Expand All @@ -103,23 +115,33 @@ public void channelReadComplete(ChannelHandlerContext ctx) {
}

@Override
@SuppressWarnings("checkstyle:methodlength")
protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
LOGGER.fine(() -> String.format("[Handler: %s] Received object: %s", System.identityHashCode(this), msg.getClass()));
LOGGER.fine(() -> String.format("[Handler: %s, Channel: %s] Received object: %s",
System.identityHashCode(this), System.identityHashCode(ctx.channel()), msg.getClass()));

if (msg instanceof HttpRequest) {

// Turns off auto read
ctx.channel().config().setAutoRead(false);

// Reset internal state on new request
reset();

// Check that HTTP decoding was successful or return 400
HttpRequest request = (HttpRequest) msg;
try {
checkDecoderResult(request);
} catch (Throwable e) {
send400BadRequest(ctx, e.getMessage());
return;
}

// Certificate management
request.headers().remove(Http.Header.X_HELIDON_CN);
Optional.ofNullable(ctx.channel().attr(CERTIFICATE_NAME).get())
.ifPresent(name -> request.headers().set(Http.Header.X_HELIDON_CN, name));

// Queue, context and publisher creation
ReferenceHoldingQueue<DataChunk> queue = new ReferenceHoldingQueue<>();
queues.add(queue);
requestContext = new RequestContext(new HttpRequestScopedPublisher(ctx, queue), request);
Expand All @@ -137,6 +159,28 @@ protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
return;
}

// If context length is greater than maximum allowed, return 413 response
if (maxPayloadSize >= 0) {
String contentLength = request.headers().get(Http.Header.CONTENT_LENGTH);
if (contentLength != null) {
try {
long value = Long.parseLong(contentLength);
if (value > maxPayloadSize) {
LOGGER.fine(() -> String.format("[Handler: %s, Channel: %s] Payload length over max %d > %d",
System.identityHashCode(this), System.identityHashCode(ctx.channel()),
value, maxPayloadSize));
ignorePayload = true;
send413PayloadTooLarge(ctx);
return;
}
} catch (NumberFormatException e) {
send400BadRequest(ctx, Http.Header.CONTENT_LENGTH + " header is invalid");
return;
}
}
}

// Create response and handler for its completion
BareResponseImpl bareResponse =
new BareResponseImpl(ctx, request, publisherRef::isCompleted, Thread.currentThread(), requestId);
bareResponse.whenCompleted()
Expand Down Expand Up @@ -206,8 +250,22 @@ protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
// payload is not consumed and the response is already sent; we must close the connection
LOGGER.finer(() -> "Closing connection because request payload was not consumed; method: " + method);
ctx.close();
} else {
requestContext.publisher().emit(content);
} else if (!ignorePayload) {
// Check payload size if a maximum has been set
if (maxPayloadSize >= 0) {
actualPayloadSize += content.readableBytes();
if (actualPayloadSize > maxPayloadSize) {
LOGGER.fine(() -> String.format("[Handler: %s, Channel: %s] Chunked Payload over max %d > %d",
System.identityHashCode(this), System.identityHashCode(ctx.channel()),
actualPayloadSize, maxPayloadSize));
ignorePayload = true;
send413PayloadTooLarge(ctx);
} else {
requestContext.publisher().emit(content);
}
} else {
requestContext.publisher().emit(content);
}
}
}

Expand All @@ -234,6 +292,11 @@ protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
}
}

/**
* Check that an HTTP message has been successfully decoded.
*
* @param request The HTTP request.
*/
private static void checkDecoderResult(HttpRequest request) {
DecoderResult decoderResult = request.decoderResult();
if (decoderResult.isFailure()) {
Expand Down Expand Up @@ -293,6 +356,16 @@ private static void send400BadRequest(ChannelHandlerContext ctx, String message)

}

/**
* Returns a 413 (Payload Too Large) response.
*
* @param ctx Channel context.
*/
private void send413PayloadTooLarge(ChannelHandlerContext ctx) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, REQUEST_ENTITY_TOO_LARGE);
ctx.write(response);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (requestContext != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ public void initChannel(SocketChannel ch) {
}

// Helidon's forwarding handler
p.addLast(new ForwardingHandler(routing, webServer, sslEngine, queues, requestDecoder));
p.addLast(new ForwardingHandler(routing, webServer, sslEngine, queues,
requestDecoder, soConfig.maxPayloadSize()));

// Cleanup queues as part of event loop
ch.eventLoop().execute(this::clearQueues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ static class SocketConfig implements SocketConfiguration {
private final boolean validateHeaders;
private final int initialBufferSize;
private final boolean enableCompression;
private final long maxPayloadSize;

/**
* Creates new instance.
Expand All @@ -195,6 +196,7 @@ static class SocketConfig implements SocketConfiguration {
this.validateHeaders = builder.validateHeaders();
this.initialBufferSize = builder.initialBufferSize();
this.enableCompression = builder.enableCompression();
this.maxPayloadSize = builder.maxPayloadSize();

WebServerTls webServerTls = builder.tlsConfig();
if (webServerTls.enabled()) {
Expand Down Expand Up @@ -287,5 +289,10 @@ public int initialBufferSize() {
public boolean enableCompression() {
return enableCompression;
}

@Override
public long maxPayloadSize() {
return maxPayloadSize;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,17 @@ public Builder enabledSSlProtocols(List<String> protocols) {
return this;
}

/**
* Configure maximum client payload size.
* @param size maximum payload size
* @return an updated builder
*/
@Override
public Builder maxPayloadSize(long size) {
this.defaultSocketBuilder.maxPayloadSize(size);
return this;
}

/**
* Configure experimental features.
* @param experimental experimental configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ default boolean enableCompression() {
return false;
}

/**
* Maximum size allowed for an HTTP payload in a client request. A negative
* value indicates that there is no maximum set.
*
* @return maximum payload size
*/
default long maxPayloadSize() {
return -1L;
}

/**
* Initial size of the buffer used to parse HTTP line and headers.
*
Expand Down Expand Up @@ -338,6 +348,15 @@ default B tls(Supplier<WebServerTls> tlsConfig) {
*/
B enableCompression(boolean value);

/**
* Set a maximum payload size for a client request. Can prevent DoS
* attacks.
*
* @param size maximum payload size
* @return this builder
*/
B maxPayloadSize(long size);

/**
* Update this socket configuration from a {@link io.helidon.config.Config}.
*
Expand All @@ -351,6 +370,7 @@ default B config(Config config) {
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);
config.get("max-payload-size").asInt().ifPresent(this::maxPayloadSize);

DeprecatedConfig.get(config, "timeout-millis", "timeout")
.asInt()
Expand Down Expand Up @@ -411,6 +431,7 @@ final class Builder implements SocketConfigurationBuilder<Builder>, io.helidon.c
private boolean validateHeaders = true;
private int initialBufferSize = 128;
private boolean enableCompression = false;
private long maxPayloadSize = -1;

private Builder() {
}
Expand Down Expand Up @@ -578,6 +599,12 @@ public Builder maxInitialLineLength(int length) {
return this;
}

@Override
public Builder maxPayloadSize(long size) {
this.maxPayloadSize = size;
return this;
}

/**
* Configure a socket name, to bind named routings to.
*
Expand Down Expand Up @@ -716,5 +743,9 @@ int initialBufferSize() {
boolean enableCompression() {
return enableCompression;
}

long maxPayloadSize() {
return maxPayloadSize;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ public Builder enableCompression(boolean value) {
return this;
}

@Override
public Builder maxPayloadSize(long size) {
configurationBuilder.maxPayloadSize(size);
return this;
}

/**
* Configure experimental features.
* @param experimental experimental configuration
Expand Down
Loading