Authors: russell@unturf.com · brackishbert@gmail.com · foxhop.net · TimeHexOn.com Patches, unit tests, benchmarks, whitepaper, and outreach briefs. Public domain — no copyright claimed. Use freely.
473 lines
20 KiB
Java
473 lines
20 KiB
Java
/*
|
|
* Copyright (c) 2024, 2026, Oracle and/or its affiliates. All rights reserved.
|
|
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
|
*
|
|
* This code is free software; you can redistribute it and/or modify it
|
|
* under the terms of the GNU General Public License version 2 only, as
|
|
* published by the Free Software Foundation.
|
|
*
|
|
* This code is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
* version 2 for more details (a copy is included in the LICENSE file that
|
|
* accompanied this code).
|
|
*
|
|
* You should have received a copy of the GNU General Public License version
|
|
* 2 along with this work; if not, write to the Free Software Foundation,
|
|
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
*
|
|
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
|
|
* or visit www.oracle.com if you need additional information or have any
|
|
* questions.
|
|
*/
|
|
|
|
import jdk.httpclient.test.lib.common.HttpServerAdapters;
|
|
import jdk.httpclient.test.lib.quic.QuicStandaloneServer;
|
|
import jdk.internal.net.http.common.Logger;
|
|
import jdk.internal.net.http.common.Utils;
|
|
import jdk.internal.net.quic.QuicVersion;
|
|
import jdk.test.lib.net.SimpleSSLContext;
|
|
import jdk.test.lib.net.URIBuilder;
|
|
import org.junit.jupiter.api.AfterAll;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
|
import org.junit.jupiter.params.provider.MethodSource;
|
|
|
|
import javax.net.ssl.SSLContext;
|
|
import java.io.IOException;
|
|
import java.io.OutputStream;
|
|
import java.net.ProtocolException;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpClient.Version;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.net.http.HttpResponse.BodyHandlers;
|
|
import java.util.HexFormat;
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
import java.util.function.BooleanSupplier;
|
|
|
|
import static java.net.http.HttpClient.Builder.NO_PROXY;
|
|
import static java.net.http.HttpOption.H3_DISCOVERY;
|
|
import static java.net.http.HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
/*
|
|
* @test
|
|
* @bug 8369595
|
|
* @summary Verifies that the HTTP/3 malformed responses are correctly handled
|
|
* @library /test/jdk/java/net/httpclient/lib
|
|
* /test/lib
|
|
* @run junit ${test.main.class}
|
|
*/
|
|
|
|
/// Verifies that the HTTP/3 malformed responses are correctly handled.
|
|
///
|
|
/// ### HTTP/3 `HEADERS` frame & QPACK Field Section encoding crash course
|
|
///
|
|
/// Consider an HTTP/3 `HEADERS` frame that carries:
|
|
///
|
|
/// ```
|
|
/// :status: 200
|
|
/// content-length: 2
|
|
/// ```
|
|
///
|
|
/// This will be encoded as the following byte sequence:
|
|
///
|
|
/// ```
|
|
/// 01 06 00 00 D9 54 01 32
|
|
/// ```
|
|
///
|
|
/// Let's start with decoding the HTTP/3 frame:
|
|
///
|
|
/// - `01`: Frame Type (`01` denotes `HEADERS``)
|
|
///
|
|
/// - `06`: Payload length (6 bytes)
|
|
///
|
|
/// Figured this is a `HEADERS` frame containing 6 bytes: `00 00 D9 54 01 32`.
|
|
/// Let's decode the QPACK Field Section
|
|
///
|
|
/// - `00`: Required Insert Count (0)
|
|
///
|
|
/// - `00`: Base (0)
|
|
///
|
|
/// - `D9`:
|
|
/// QPACK has a static table (indexed from 0) and `:status: 200` is at the
|
|
/// static-table index 25.
|
|
///
|
|
/// ```
|
|
/// D9 = <1101 1001>
|
|
/// = <1> (Indexed Field Line)
|
|
/// + <1> (Static Table)
|
|
/// + <01 1001> (entry index = 25)
|
|
/// ```
|
|
///
|
|
/// - `54 01 32`:
|
|
/// `content-length: 2` can be encoded as a *literal field line with name
|
|
/// reference* using the static name `content-length` (static index 4) and
|
|
/// the literal value `2`.
|
|
///
|
|
/// ```
|
|
/// 54 01 32 = <0101 0100 0000 0001 0011 0010>
|
|
/// = <0> (Literal Field Line)
|
|
/// + <1> (Name Reference)
|
|
/// + <0100> (entry index = 4)
|
|
/// + <0000 0001> (value length = 1 byte)
|
|
/// + <0011 0010> (value = ASCII "2" = 0x32 = 50)
|
|
/// ```
|
|
///
|
|
/// Note that the `value length` field (i.e., `0000 0001`) follows a variable
|
|
/// coding scheme:
|
|
///
|
|
/// | Prefix | Total size | Payload size | Max value |
|
|
/// | --------------------- | ---------- | ------------ | ------------- |
|
|
/// | `00xx xxxx` | 1 byte | 6 bits | 63 |
|
|
/// | `01xx xxxx xxxx xxxx` | 2 bytes | 14 bits | 16,383 |
|
|
/// | `10xx xxx…` | 4 bytes | 30 bits | 1,073,741,823 |
|
|
/// | `11xx xxx…` | 8 bytes | 62 bits | 4.61e18 |
|
|
class H3MalformedResponseTest {
|
|
|
|
private static final String CLASS_NAME = H3MalformedResponseTest.class.getSimpleName();
|
|
|
|
private static final Logger LOGGER = Utils.getDebugLogger(CLASS_NAME::toString, Utils.DEBUG);
|
|
|
|
private static final SSLContext SSL_CONTEXT = SimpleSSLContext.findSSLContext();
|
|
|
|
private static QuicStandaloneServer SERVER;
|
|
|
|
private static HttpRequest REQUEST;
|
|
|
|
@BeforeAll
|
|
static void setUp() throws Exception {
|
|
|
|
// Create and start the server
|
|
SERVER = QuicStandaloneServer.newBuilder()
|
|
.availableVersions(new QuicVersion[]{QuicVersion.QUIC_V1})
|
|
.sslContext(SSL_CONTEXT)
|
|
.alpn("h3")
|
|
.build();
|
|
SERVER.start();
|
|
LOGGER.log("Server is started at {}", SERVER.getAddress());
|
|
|
|
// Create the request
|
|
var requestURI = URIBuilder.newBuilder()
|
|
.scheme("https")
|
|
.loopback()
|
|
.port(SERVER.getAddress().getPort())
|
|
.path("/" + CLASS_NAME)
|
|
.build();
|
|
REQUEST = HttpRequest.newBuilder(requestURI)
|
|
.version(Version.HTTP_3)
|
|
.setOption(H3_DISCOVERY, HTTP_3_URI_ONLY)
|
|
.build();
|
|
|
|
}
|
|
|
|
@AfterAll
|
|
static void tearDown() {
|
|
close("server", SERVER);
|
|
}
|
|
|
|
private static void close(String name, AutoCloseable closeable) {
|
|
if (closeable != null) {
|
|
LOGGER.log("Closing {}", name);
|
|
try {
|
|
closeable.close();
|
|
} catch (Exception e) {
|
|
LOGGER.log("Could not close " + name, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Malformed responses that should not be accepted by the client, but
|
|
/// should neither cause the connection to get closed.
|
|
static Object[][] malformedResponsesPreservingConnection() {
|
|
return new Object[][]{
|
|
{"empty", IOException.class, parseHex("")},
|
|
{"non-final response", IOException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00" // :status:100
|
|
)},
|
|
{"uppercase header name", ProtocolException.class, parseHex(
|
|
"01090000", // headers, length 9, section prefix
|
|
"d9", // :status:200
|
|
"234147450130", // AGE:0
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"content too long", IOException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"d9", // :status:200
|
|
"c4", // content-length:0
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"content too short", IOException.class, parseHex(
|
|
"01060000", // headers, length 6, section prefix
|
|
"d9", // :status:200
|
|
"540132", // content-length:2
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"text in content-length", ProtocolException.class, parseHex(
|
|
"01060000" + // headers, length 6, section prefix
|
|
"d9" + // :status:200
|
|
"540161" + // content-length:a
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"connection: close", ProtocolException.class, parseHex(
|
|
"01150000", // headers, length 21, section prefix
|
|
"d9", // :status:200
|
|
"2703636F6E6E656374696F6E05636C6F7365" + // connection:close
|
|
"000100" // data, 1 byte
|
|
)},
|
|
// request pseudo-headers in response
|
|
{":method in response", ProtocolException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"d9", // :status:200
|
|
"d1", // :method:get
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{":authority in response", ProtocolException.class, parseHex(
|
|
"01100000", // headers, length 16, section prefix
|
|
"d9", // :status:200
|
|
"508b089d5c0b8170dc702fbce7", // :authority
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{":path in response", ProtocolException.class, parseHex(
|
|
"010a0000", // headers, length 10, section prefix
|
|
"d9", // :status:200
|
|
"51856272d141ff", // :path
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{":scheme in response", ProtocolException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"d9", // :status:200
|
|
"d7", // :scheme:https
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"undefined pseudo-header", ProtocolException.class, parseHex(
|
|
"01080000", // headers, length 8, section prefix
|
|
"d9", // :status:200
|
|
"223A6D0130", // :m:0
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"pseudo-header after regular", ProtocolException.class, parseHex(
|
|
"011a0000", // headers, length 26, section prefix
|
|
"5f5094ca3ee35a74a6b589418b5258132b1aa496ca8747", //user-agent
|
|
"d9", // :status:200
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"trailer", IOException.class, parseHex(
|
|
"01020000" // headers, length 2, section prefix
|
|
)},
|
|
{"trailer+data", IOException.class, parseHex(
|
|
"01020000", // headers, length 2, section prefix
|
|
"000100" // data, 1 byte
|
|
)},
|
|
// valid characters include \t, 0x20-0x7e, 0x80-0xff (RFC 9110, section 5.5)
|
|
{"invalid character in field value 00", ProtocolException.class, parseHex(
|
|
"01060000", // headers, length 6, section prefix
|
|
"d9", // :status:200
|
|
"570100", // etag:\0
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"invalid character in field value 0a", ProtocolException.class, parseHex(
|
|
"01060000", // headers, length 6, section prefix
|
|
"d9", // :status:200
|
|
"57010a", // etag:\n
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"invalid character in field value 0d", ProtocolException.class, parseHex(
|
|
"01060000", // headers, length 6, section prefix
|
|
"d9", // :status:200
|
|
"57010d", // etag:\r
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"invalid character in field value 7f", ProtocolException.class, parseHex(
|
|
"01060000", // headers, length 6, section prefix
|
|
"d9", // :status:200
|
|
"57017f", // etag: 0x7f
|
|
"000100" // data, 1 byte
|
|
)},
|
|
};
|
|
}
|
|
|
|
/// Malformed responses that should not be accepted by the client.
|
|
/// They might or might not cause the connection to get closed (`H3_FRAME_UNEXPECTED`).
|
|
static Object[][] malformedResponses() {
|
|
// data before headers is covered by H3ErrorHandlingTest
|
|
return new Object[][]{
|
|
{"100+data", IOException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00", // :status:100
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"100+data+200", IOException.class, parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00", // :status:100
|
|
"000100", // data, 1 byte
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9" // :status:200
|
|
)},
|
|
{"200+data+200", IOException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9" // :status:200
|
|
)},
|
|
{"200+data+100", IOException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00" // :status:100
|
|
)},
|
|
{"200+data+trailers+data", ProtocolException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"01020000", // trailers, length 2, section prefix
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"200+trailers+data", IOException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"01020000", // trailers, length 2, section prefix
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"200+200", IOException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9" // :status:200
|
|
)},
|
|
{"200+100", IOException.class, parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00" // :status:100
|
|
)},
|
|
};
|
|
}
|
|
|
|
/// Well-formed responses that should be accepted by the client.
|
|
static Object[][] wellFormedResponses() {
|
|
return new Object[][]{
|
|
{"100+200+data+reserved", parseHex(
|
|
"01040000", // headers, length 4, section prefix
|
|
"ff00", // :status:100
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"210100" // reserved, 1 byte
|
|
)},
|
|
{"200+data+reserved", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"210100" // reserved, 1 byte
|
|
)},
|
|
{"200+data", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"200+user-agent+data", parseHex(
|
|
"011a0000", // headers, length 26, section prefix
|
|
"d9", // :status:200
|
|
"5f5094ca3ee35a74a6b589418b5258132b1aa496ca8747", //user-agent
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"200", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9" // :status:200
|
|
)},
|
|
{"200+data+data", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"000100" // data, 1 byte
|
|
)},
|
|
{"200+data+trailers", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"000100", // data, 1 byte
|
|
"01020000" // trailers, length 2, section prefix
|
|
)},
|
|
{"200+trailers", parseHex(
|
|
"01030000", // headers, length 3, section prefix
|
|
"d9", // :status:200
|
|
"01020000" // trailers, length 2, section prefix
|
|
)},
|
|
};
|
|
}
|
|
|
|
private static byte[] parseHex(String... strings) {
|
|
var buffer = new StringBuilder();
|
|
for (String string : strings) {
|
|
buffer.append(string);
|
|
}
|
|
return HexFormat.of().parseHex(buffer.toString());
|
|
}
|
|
|
|
@ParameterizedTest
|
|
@MethodSource("wellFormedResponses")
|
|
void testWellFormedResponse(String desc, byte[] serverResponseBytes) throws Exception {
|
|
var connectionTerminated = configureServerResponse(serverResponseBytes);
|
|
try (var client = createClient()) {
|
|
final HttpResponse<Void> response = client.send(REQUEST, BodyHandlers.discarding());
|
|
assertEquals(200, response.statusCode());
|
|
assertFalse(connectionTerminated.getAsBoolean(), "Expected the connection to be open");
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest
|
|
@MethodSource("malformedResponsesPreservingConnection")
|
|
void testMalformedResponsePreservingConnection(
|
|
String desc,
|
|
Class<? extends Exception> exceptionClass,
|
|
byte[] serverResponseBytes) {
|
|
var connectionTerminated = configureServerResponse(serverResponseBytes);
|
|
try (var client = createClient()) {
|
|
var exception = assertThrows(exceptionClass, () -> client.send(REQUEST, BodyHandlers.discarding()));
|
|
LOGGER.log("Got expected exception for: " + desc, exception);
|
|
assertFalse(connectionTerminated.getAsBoolean(), "Expected the connection to be open");
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest
|
|
@MethodSource("malformedResponses")
|
|
void testMalformedResponse(
|
|
String desc,
|
|
Class<? extends Exception> exceptionClass,
|
|
byte[] serverResponseBytes) {
|
|
configureServerResponse(serverResponseBytes);
|
|
try (var client = createClient()) {
|
|
var exception = assertThrows(exceptionClass, () -> client.send(REQUEST, BodyHandlers.discarding()));
|
|
LOGGER.log("Got expected exception for: " + desc, exception);
|
|
}
|
|
}
|
|
|
|
private static HttpClient createClient() {
|
|
return HttpServerAdapters.createClientBuilderForH3()
|
|
.proxy(NO_PROXY)
|
|
.version(Version.HTTP_3)
|
|
.sslContext(SSL_CONTEXT)
|
|
.build();
|
|
}
|
|
|
|
private static BooleanSupplier configureServerResponse(byte[] serverResponseBytes) {
|
|
var connectionTerminated = new AtomicBoolean();
|
|
SERVER.setHandler((c, s)-> {
|
|
try (OutputStream outputStream = s.outputStream()) {
|
|
outputStream.write(serverResponseBytes);
|
|
}
|
|
c.futureTerminationCause().handle((_, _) -> {
|
|
connectionTerminated.set(true);
|
|
return true;
|
|
});
|
|
});
|
|
return connectionTerminated::get;
|
|
}
|
|
|
|
}
|