java-topology/test/jdk/java/net/httpclient/http3/H3MalformedResponseTest.java
russell@unturf.com 0a580b313d undefect. CWE-407 — 63 sites patched across 27 ecosystems
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.
2026-03-26 17:11:57 -04:00

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;
}
}