Skip to content

Commit 350b540

Browse files
authored
Validate HTTP/2 MAX_FRAME_SIZE before applying client settings (helidon-io#11291)
* Validate HTTP/2 MAX_FRAME_SIZE before applying client settings * Add HTTP/2 integration test for invalid MAX_FRAME_SIZE returning PROTOCOL GO_AWAY Signed-off-by: Daniel Kec <daniel.kec@oracle.com>
1 parent 6f903c6 commit 350b540

3 files changed

Lines changed: 261 additions & 8 deletions

File tree

webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022, 2025 Oracle and/or its affiliates.
2+
* Copyright (c) 2022, 2026 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -219,6 +219,7 @@ public void handle(Semaphore requestSemaphore) throws InterruptedException {
219219
* @param http2Settings client settings to use
220220
*/
221221
public void clientSettings(Http2Settings http2Settings) {
222+
validateMaxFrameSize(http2Settings);
222223
this.clientSettings = http2Settings;
223224
this.receiveFrameListener.frame(ctx, 0, clientSettings);
224225
if (this.clientSettings.hasValue(Http2Setting.HEADER_TABLE_SIZE)) {
@@ -255,16 +256,23 @@ public void clientSettings(Http2Settings http2Settings) {
255256

256257
if (this.clientSettings.hasValue(Http2Setting.MAX_FRAME_SIZE)) {
257258
Long maxFrameSize = this.clientSettings.value(Http2Setting.MAX_FRAME_SIZE);
258-
// specification defines, that the frame size must be between the initial size (16384) and 2^24-1
259-
if (maxFrameSize < WindowSize.DEFAULT_MAX_FRAME_SIZE || maxFrameSize > WindowSize.MAX_MAX_FRAME_SIZE) {
260-
throw new Http2Exception(Http2ErrorCode.PROTOCOL,
261-
"Frame size must be between 2^14 and 2^24-1, but is: " + maxFrameSize);
262-
}
263-
264259
flowControl.resetMaxFrameSize(maxFrameSize.intValue());
265260
}
266261
}
267262

263+
private static void validateMaxFrameSize(Http2Settings http2Settings) {
264+
if (!http2Settings.hasValue(Http2Setting.MAX_FRAME_SIZE)) {
265+
return;
266+
}
267+
268+
Long maxFrameSize = http2Settings.value(Http2Setting.MAX_FRAME_SIZE);
269+
// specification defines, that the frame size must be between the initial size (16384) and 2^24-1
270+
if (maxFrameSize < WindowSize.DEFAULT_MAX_FRAME_SIZE || maxFrameSize > WindowSize.MAX_MAX_FRAME_SIZE) {
271+
throw new Http2Exception(Http2ErrorCode.PROTOCOL,
272+
"Frame size must be between 2^14 and 2^24-1, but is: " + maxFrameSize);
273+
}
274+
}
275+
268276
/**
269277
* Connection headers from an upgrade request from HTTP/1.1.
270278
*

webserver/http2/src/test/java/io/helidon/webserver/http2/UpgradeSettingsTest.java

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2024, 2026 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,16 +16,28 @@
1616

1717
package io.helidon.webserver.http2;
1818

19+
import java.nio.charset.StandardCharsets;
20+
import java.util.ArrayList;
1921
import java.util.Base64;
22+
import java.util.List;
23+
import java.util.concurrent.atomic.AtomicBoolean;
2024

2125
import io.helidon.common.buffers.BufferData;
26+
import io.helidon.common.buffers.DataReader;
2227
import io.helidon.common.buffers.DataWriter;
28+
import io.helidon.http.http2.Http2Exception;
29+
import io.helidon.http.http2.Http2FrameData;
30+
import io.helidon.http.http2.Http2FrameHeader;
31+
import io.helidon.http.http2.Http2FrameType;
32+
import io.helidon.http.http2.Http2GoAway;
2333
import io.helidon.http.HeaderValues;
2434
import io.helidon.http.HttpPrologue;
2535
import io.helidon.http.Method;
2636
import io.helidon.http.WritableHeaders;
2737
import io.helidon.http.http2.Http2Flag;
38+
import io.helidon.http.http2.Http2ErrorCode;
2839
import io.helidon.http.http2.Http2Settings;
40+
import io.helidon.http.http2.Http2Util;
2941
import io.helidon.webserver.ConnectionContext;
3042
import io.helidon.webserver.ListenerContext;
3143
import io.helidon.webserver.Router;
@@ -39,7 +51,12 @@
3951
import static io.helidon.http.http2.Http2Setting.MAX_FRAME_SIZE;
4052
import static io.helidon.http.http2.Http2Setting.MAX_HEADER_LIST_SIZE;
4153
import static org.hamcrest.MatcherAssert.assertThat;
54+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
4255
import static org.hamcrest.Matchers.is;
56+
import static org.hamcrest.Matchers.notNullValue;
57+
import static org.junit.jupiter.api.Assertions.assertThrows;
58+
import static org.mockito.ArgumentMatchers.any;
59+
import static org.mockito.Mockito.doAnswer;
4360
import static org.mockito.Mockito.mock;
4461
import static org.mockito.Mockito.when;
4562

@@ -62,6 +79,7 @@ public UpgradeSettingsTest() {
6279
when(ctx.router()).thenReturn(Router.empty());
6380
when(ctx.listenerContext()).thenReturn(mock(ListenerContext.class));
6481
when(ctx.dataWriter()).thenReturn(dataWriter);
82+
when(ctx.dataReader()).thenReturn(mock(DataReader.class));
6583
}
6684

6785
@Test
@@ -91,6 +109,65 @@ void urlEncodedSettings() {
91109
assertThat(s.presentValue(MAX_HEADER_LIST_SIZE).orElseThrow(), is(256L));
92110
}
93111

112+
@Test
113+
void invalidMaxFrameSizeDoesNotReplaceLastValidSettings() {
114+
Http2Connection connection = new Http2Connection(ctx, Http2Config.create(), List.of());
115+
116+
Http2Settings validSettings = Http2Settings.builder()
117+
.add(MAX_FRAME_SIZE, 16384L)
118+
.build();
119+
connection.clientSettings(validSettings);
120+
121+
Http2Settings invalidSettings = Http2Settings.builder()
122+
.add(MAX_FRAME_SIZE, 0L)
123+
.build();
124+
125+
Http2Exception exception = assertThrows(Http2Exception.class,
126+
() -> connection.clientSettings(invalidSettings));
127+
128+
assertThat(exception.code(), is(io.helidon.http.http2.Http2ErrorCode.PROTOCOL));
129+
assertThat(connection.clientSettings().value(MAX_FRAME_SIZE), is(16384L));
130+
}
131+
132+
@Test
133+
void invalidMaxFrameSizeHandledWithProtocolGoAway() throws InterruptedException {
134+
List<BufferData> writtenFrames = new ArrayList<>();
135+
DataWriter dataWriter = mock(DataWriter.class);
136+
doAnswer(invocation -> {
137+
BufferData data = invocation.getArgument(0);
138+
writtenFrames.add(data.copy());
139+
return null;
140+
}).when(dataWriter).writeNow(any(BufferData.class));
141+
142+
ConnectionContext connectionContext = mock(ConnectionContext.class);
143+
when(connectionContext.router()).thenReturn(Router.empty());
144+
when(connectionContext.listenerContext()).thenReturn(mock(ListenerContext.class));
145+
when(connectionContext.dataWriter()).thenReturn(dataWriter);
146+
when(connectionContext.dataReader()).thenReturn(invalidMaxFrameSizeReader());
147+
148+
Http2Connection connection = new Http2Connection(connectionContext,
149+
Http2Config.builder().sendErrorDetails(true).build(),
150+
List.of());
151+
connection.expectPreface();
152+
connection.handle(mock(io.helidon.common.concurrency.limits.Limit.class));
153+
154+
assertThat(writtenFrames.size(), greaterThanOrEqualTo(2));
155+
156+
BufferData goAwayData = writtenFrames.get(writtenFrames.size() - 1);
157+
byte[] headerBytes = new byte[Http2FrameHeader.LENGTH];
158+
goAwayData.read(headerBytes);
159+
Http2FrameHeader frameHeader = Http2FrameHeader.create(BufferData.create(headerBytes));
160+
assertThat(frameHeader.type(), is(Http2FrameType.GO_AWAY));
161+
162+
byte[] payloadBytes = new byte[frameHeader.length()];
163+
goAwayData.read(payloadBytes);
164+
Http2GoAway goAway = Http2GoAway.create(BufferData.create(payloadBytes));
165+
assertThat(goAway, notNullValue());
166+
assertThat(goAway.errorCode(), is(Http2ErrorCode.PROTOCOL));
167+
assertThat(new String(payloadBytes, 8, payloadBytes.length - 8, StandardCharsets.UTF_8),
168+
is("Frame size must be between 2^14 and 2^24-1, but is: 0"));
169+
}
170+
94171
Http2Settings upgrade(String http2Settings) {
95172
WritableHeaders<?> headers = WritableHeaders.create().add(HeaderValues.create("HTTP2-Settings", http2Settings));
96173
Http2Upgrader http2Upgrader = Http2Upgrader.create(Http2Config.create());
@@ -105,4 +182,16 @@ byte[] settingsToBytes(Http2Settings settings) {
105182
settingsFrameData.read(b);
106183
return b;
107184
}
185+
186+
private static DataReader invalidMaxFrameSizeReader() {
187+
Http2FrameData frameData = Http2Settings.builder()
188+
.add(MAX_FRAME_SIZE, 0L)
189+
.build()
190+
.toFrameData(null, 0, Http2Flag.SettingsFlags.create(0));
191+
BufferData input = BufferData.create(Http2Util.prefaceData(),
192+
BufferData.create(frameData.header().write(), frameData.data()));
193+
byte[] bytes = input.readBytes();
194+
AtomicBoolean delivered = new AtomicBoolean();
195+
return DataReader.create(() -> delivered.compareAndSet(false, true) ? bytes : null);
196+
}
108197
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2026 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.helidon.webserver.tests.http2;
18+
19+
import java.net.URI;
20+
import java.nio.charset.StandardCharsets;
21+
import java.time.Duration;
22+
import java.util.List;
23+
import java.util.concurrent.CompletableFuture;
24+
import java.util.concurrent.ExecutionException;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.TimeoutException;
27+
28+
import io.helidon.common.buffers.BufferData;
29+
import io.helidon.common.buffers.DataReader;
30+
import io.helidon.common.tls.Tls;
31+
import io.helidon.http.Method;
32+
import io.helidon.http.http2.Http2ConnectionWriter;
33+
import io.helidon.http.http2.Http2ErrorCode;
34+
import io.helidon.http.http2.Http2Flag;
35+
import io.helidon.http.http2.Http2FrameData;
36+
import io.helidon.http.http2.Http2FrameHeader;
37+
import io.helidon.http.http2.Http2FrameType;
38+
import io.helidon.http.http2.Http2GoAway;
39+
import io.helidon.http.http2.Http2Setting;
40+
import io.helidon.http.http2.Http2Settings;
41+
import io.helidon.http.http2.Http2Util;
42+
import io.helidon.webclient.api.ClientUri;
43+
import io.helidon.webclient.api.ConnectionKey;
44+
import io.helidon.webclient.api.DefaultDnsResolver;
45+
import io.helidon.webclient.api.DnsAddressLookup;
46+
import io.helidon.webclient.api.Proxy;
47+
import io.helidon.webclient.api.TcpClientConnection;
48+
import io.helidon.webclient.api.WebClient;
49+
import io.helidon.webserver.WebServer;
50+
import io.helidon.webserver.WebServerConfig;
51+
import io.helidon.webserver.http.HttpRouting;
52+
import io.helidon.webserver.http2.Http2Config;
53+
import io.helidon.webserver.http2.Http2Route;
54+
import io.helidon.webserver.testing.junit5.ServerTest;
55+
import io.helidon.webserver.testing.junit5.SetUpRoute;
56+
import io.helidon.webserver.testing.junit5.SetUpServer;
57+
58+
import org.junit.jupiter.api.Test;
59+
import static io.netty.handler.codec.http2.Http2CodecUtil.FRAME_HEADER_LENGTH;
60+
import static org.hamcrest.MatcherAssert.assertThat;
61+
import static org.hamcrest.Matchers.is;
62+
63+
@ServerTest
64+
public class InvalidSettingsTest {
65+
private static final Duration TIMEOUT = Duration.ofSeconds(10);
66+
private static final String INVALID_MAX_FRAME_SIZE_MESSAGE =
67+
"Frame size must be between 2^14 and 2^24-1, but is: 0";
68+
private final WebServer server;
69+
70+
InvalidSettingsTest(WebServer server) {
71+
this.server = server;
72+
}
73+
74+
@SetUpRoute
75+
static void router(HttpRouting.Builder router) {
76+
router.route(Http2Route.route(Method.GET, "/", (req, res) -> res.send("pong")));
77+
}
78+
79+
@SetUpServer
80+
static void setup(WebServerConfig.Builder server) {
81+
server.addProtocol(Http2Config.builder().sendErrorDetails(true).build());
82+
}
83+
84+
@Test
85+
void invalidMaxFrameSizeReturnsProtocolGoAway() throws InterruptedException, ExecutionException, TimeoutException {
86+
TcpClientConnection conn = connect();
87+
try {
88+
conn.writer().writeNow(Http2Util.prefaceData());
89+
Http2ConnectionWriter dataWriter = new Http2ConnectionWriter(conn.helidonSocket(), conn.writer(), List.of());
90+
91+
dataWriter.write(settingsFrame(16384L));
92+
dataWriter.write(settingsFrame(0L));
93+
94+
GoAwayResult goAway = awaitGoAway(conn.reader()).get(TIMEOUT.getSeconds(), TimeUnit.SECONDS);
95+
assertThat(goAway.errorCode(), is(Http2ErrorCode.PROTOCOL));
96+
assertThat(goAway.details(), is(INVALID_MAX_FRAME_SIZE_MESSAGE));
97+
} finally {
98+
conn.closeResource();
99+
}
100+
}
101+
102+
private TcpClientConnection connect() {
103+
ClientUri clientUri = ClientUri.create(URI.create("http://localhost:" + server.port()));
104+
ConnectionKey connectionKey = ConnectionKey.create(clientUri.scheme(),
105+
clientUri.host(),
106+
clientUri.port(),
107+
Tls.builder().enabled(false).build(),
108+
DefaultDnsResolver.create(),
109+
DnsAddressLookup.defaultLookup(),
110+
Proxy.noProxy());
111+
112+
return TcpClientConnection.create(WebClient.builder()
113+
.baseUri(clientUri)
114+
.build(),
115+
connectionKey,
116+
List.of(),
117+
connection -> false,
118+
connection -> {
119+
})
120+
.connect();
121+
}
122+
123+
private static Http2FrameData settingsFrame(long maxFrameSize) {
124+
Http2Settings http2Settings = Http2Settings.builder()
125+
.add(Http2Setting.INITIAL_WINDOW_SIZE, 65535L)
126+
.add(Http2Setting.MAX_FRAME_SIZE, maxFrameSize)
127+
.add(Http2Setting.ENABLE_PUSH, false)
128+
.build();
129+
return http2Settings.toFrameData(null, 0, Http2Flag.SettingsFlags.create(0));
130+
}
131+
132+
private static CompletableFuture<GoAwayResult> awaitGoAway(DataReader reader) {
133+
CompletableFuture<GoAwayResult> gotGoAway = new CompletableFuture<>();
134+
Thread.ofVirtual().start(() -> {
135+
for (; ; ) {
136+
BufferData frameHeaderBuffer = reader.readBuffer(FRAME_HEADER_LENGTH);
137+
Http2FrameHeader frameHeader = Http2FrameHeader.create(frameHeaderBuffer);
138+
BufferData data = reader.readBuffer(frameHeader.length());
139+
if (frameHeader.type() == Http2FrameType.GO_AWAY) {
140+
byte[] payloadBytes = data.readBytes();
141+
Http2GoAway http2GoAway = Http2GoAway.create(BufferData.create(payloadBytes));
142+
gotGoAway.complete(new GoAwayResult(http2GoAway.errorCode(),
143+
new String(payloadBytes,
144+
Integer.BYTES * 2,
145+
payloadBytes.length - (Integer.BYTES * 2),
146+
StandardCharsets.UTF_8)));
147+
break;
148+
}
149+
}
150+
});
151+
return gotGoAway;
152+
}
153+
154+
private record GoAwayResult(Http2ErrorCode errorCode, String details) {
155+
}
156+
}

0 commit comments

Comments
 (0)