diff --git a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c index 63b3a366..33392234 100644 --- a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c +++ b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c @@ -66,6 +66,8 @@ typedef struct ih2_conn { void *user; ih2_stream *streams; int manual_flow; /* DATA is credited through ih2_consume, not by nghttp2 */ + nghttp2_nv *nv; /* the fields of the response being submitted; keeps its size */ + size_t nv_cap; } ih2_conn; static ih2_stream *ih2_stream_find(ih2_conn *c, int32_t stream_id) @@ -176,6 +178,17 @@ static int on_stream_close(nghttp2_session *session, int32_t stream_id, uint32_t return 0; } +/* nghttp2 drops a response whose header block is past its limit (64 KiB) with no frame on the wire, which would leave + * the stream open for good. Only that error: a stream already gone is not to be reset again (RFC 9113 5.4.2). */ +static int on_frame_not_send(nghttp2_session *session, const nghttp2_frame *frame, int lib_error_code, void *user_data) +{ + (void)user_data; + if (frame->hd.type == NGHTTP2_HEADERS && lib_error_code == NGHTTP2_ERR_FRAME_SIZE_ERROR) { + return nghttp2_submit_rst_stream(session, NGHTTP2_FLAG_NONE, frame->hd.stream_id, NGHTTP2_INTERNAL_ERROR); + } + return 0; +} + /* Copy the next slice of the request body into nghttp2's buffer. * * Deliberately NOT NGHTTP2_DATA_FLAG_NO_COPY. With NO_COPY nghttp2 hands the frame to @@ -257,6 +270,7 @@ static ih2_conn *ih2_new(ih2_callbacks cbs, void *user, int server, int manual_f nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, on_frame_recv); nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, on_data_chunk_recv); nghttp2_session_callbacks_set_on_stream_close_callback(callbacks, on_stream_close); + nghttp2_session_callbacks_set_on_frame_not_send_callback(callbacks, on_frame_not_send); int rv; if (manual_flow) { @@ -312,18 +326,21 @@ ih2_conn *ih2_client_new(ih2_callbacks cbs, void *user) /* Headers arrive packed as [u16 namelen][name][u16 valuelen][value]... - the same format the * nghttp3 shim takes, so the managed packing code is shared in shape. * - * Returns the count, or a negative nghttp2 error. Truncating on overflow would silently DROP - * headers, and an omitted authorization is worse than a failed request, so it refuses instead. */ -#define IH2_MAX_HEADERS 64 - -static int ih2_unpack_headers(const uint8_t *headers, size_t headers_len, nghttp2_nv *nv) + * Unpacks into c->nv, which nghttp2 copies out of during submit. Returns the count, or a negative nghttp2 error. */ +static int ih2_unpack_headers(ih2_conn *c, const uint8_t *headers, size_t headers_len) { size_t count = 0; size_t off = 0; while (off + 2 <= headers_len) { - if (count == IH2_MAX_HEADERS) { - return NGHTTP2_ERR_INVALID_ARGUMENT; + if (count == c->nv_cap) { + size_t cap = c->nv_cap == 0 ? 64 : c->nv_cap * 2; + nghttp2_nv *grown = realloc(c->nv, cap * sizeof(*grown)); + if (grown == NULL) { + return NGHTTP2_ERR_NOMEM; + } + c->nv = grown; + c->nv_cap = cap; } uint16_t namelen; @@ -344,11 +361,11 @@ static int ih2_unpack_headers(const uint8_t *headers, size_t headers_len, nghttp const uint8_t *value = headers + off; off += valuelen; - nv[count].name = (uint8_t *)name; - nv[count].namelen = namelen; - nv[count].value = (uint8_t *)value; - nv[count].valuelen = valuelen; - nv[count].flags = NGHTTP2_NV_FLAG_NONE; /* nghttp2 copies during submit */ + c->nv[count].name = (uint8_t *)name; + c->nv[count].namelen = namelen; + c->nv[count].value = (uint8_t *)value; + c->nv[count].valuelen = valuelen; + c->nv[count].flags = NGHTTP2_NV_FLAG_NONE; /* nghttp2 copies during submit */ count++; } @@ -398,8 +415,7 @@ int ih2_submit_response_stream(ih2_conn *c, int32_t stream_id, return NGHTTP2_ERR_STREAM_CLOSED; /* the peer reset it: no callback would ever free a stream attached now */ } - nghttp2_nv nv[IH2_MAX_HEADERS]; - int count = ih2_unpack_headers(headers, headers_len, nv); + int count = ih2_unpack_headers(c, headers, headers_len); if (count < 0) { return count; } @@ -420,7 +436,7 @@ int ih2_submit_response_stream(ih2_conn *c, int32_t stream_id, provider.source.ptr = s; provider.read_callback = read_body; - int rv = nghttp2_submit_response(c->session, stream_id, nv, (size_t)count, &provider); + int rv = nghttp2_submit_response(c->session, stream_id, c->nv, (size_t)count, &provider); if (rv != 0) { ih2_stream_drop(c, stream_id); } @@ -522,8 +538,7 @@ int ih2_submit_response(ih2_conn *c, int32_t stream_id, return 0; /* the peer reset it: no callback would ever free a body attached now, and an error fails the connection */ } - nghttp2_nv nv[IH2_MAX_HEADERS]; - int count = ih2_unpack_headers(headers, headers_len, nv); + int count = ih2_unpack_headers(c, headers, headers_len); if (count < 0) { return count; } @@ -548,7 +563,7 @@ int ih2_submit_response(ih2_conn *c, int32_t stream_id, provider_ptr = &provider; } - int rv = nghttp2_submit_response(c->session, stream_id, nv, (size_t)count, provider_ptr); + int rv = nghttp2_submit_response(c->session, stream_id, c->nv, (size_t)count, provider_ptr); if (rv != 0 && s != NULL) { ih2_stream_drop(c, stream_id); } @@ -563,8 +578,7 @@ int32_t ih2_submit_request(ih2_conn *c, const uint8_t *headers, size_t headers_l return NGHTTP2_ERR_INVALID_STATE; } - nghttp2_nv nv[IH2_MAX_HEADERS]; - int count = ih2_unpack_headers(headers, headers_len, nv); + int count = ih2_unpack_headers(c, headers, headers_len); if (count < 0) { return count; } @@ -585,7 +599,7 @@ int32_t ih2_submit_request(ih2_conn *c, const uint8_t *headers, size_t headers_l /* Unlike a response, the stream id is not known until submit returns - so the body is * registered after, and read_body cannot run before then (nghttp2 has no stream to read for). */ - int32_t stream_id = nghttp2_submit_request(c->session, NULL, nv, (size_t)count, provider_ptr, NULL); + int32_t stream_id = nghttp2_submit_request(c->session, NULL, c->nv, (size_t)count, provider_ptr, NULL); if (stream_id < 0) { if (s != NULL) { free(s->body); @@ -678,6 +692,7 @@ void ih2_free(ih2_conn *c) ih2_stream_drop(c, c->streams->stream_id); } nghttp2_session_del(c->session); + free(c->nv); free(c); } diff --git a/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so b/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so index fdd32b6d..92924f3c 100755 Binary files a/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so and b/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so differ diff --git a/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so b/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so index 10a0b63e..62318fe9 100755 Binary files a/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so and b/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so differ diff --git a/tests/Ioxide.Tests.E2E/Program.cs b/tests/Ioxide.Tests.E2E/Program.cs index 513dd733..555a84cf 100644 --- a/tests/Ioxide.Tests.E2E/Program.cs +++ b/tests/Ioxide.Tests.E2E/Program.cs @@ -63,6 +63,7 @@ private static int Main() Http2BodyTests.Register(runner); Http2StreamedFaultTests.Register(runner); Http2BufferedFaultTests.Register(runner); + Nghttp2ManyHeadersTests.Register(runner); StreamedPeerGoneTests.Register(runner); QuicStreamingCreditTests.Register(runner); Http3BodyTests.Register(runner); diff --git a/tests/Ioxide.Tests.E2E/Protocols/Nghttp2ManyHeadersTests.cs b/tests/Ioxide.Tests.E2E/Protocols/Nghttp2ManyHeadersTests.cs new file mode 100644 index 00000000..9f8ffa1c --- /dev/null +++ b/tests/Ioxide.Tests.E2E/Protocols/Nghttp2ManyHeadersTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Text; +using ioxide.nghttp2; + +namespace Ioxide.Tests; + +/// +/// A response's fields are not capped by the shim: each reaches the peer, however many a handler sets. +/// +internal static class Nghttp2ManyHeadersTests +{ + public static void Register(Runner runner) + { + // 64 and 65 fields with :status are either side of the first growth, and 201 past the second. + foreach (int fields in new[] { 63, 64, 200 }) + { + foreach (bool asStream in new[] { false, true }) + { + int count = fields; + bool streamed = asStream; + runner.Test($"nghttp2: a {(streamed ? "streamed" : "buffered")} response with {count} headers of its own reaches the peer whole", () => + { + var response = new Nghttp2Response { Status = 200 }; + for (int i = 0; i < count; i++) + { + response.Headers.Add(Encoding.ASCII.GetBytes($"x-{i}"), "v"u8.ToArray()); + } + + int port = TestServer.Start(async (_, conn) => + { + try + { + var connection = new Nghttp2Connection(conn); + await (streamed + ? connection.RunAsync(async (_, writer) => + { + writer.WriteHeaders(response); + await writer.FlushAsync(); + }) + : connection.RunAsync(_ => response)); + } + finally + { + conn.DecRef(); + } + }); + + using var client = new HttpClient(new SocketsHttpHandler()) + { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, + Timeout = TimeSpan.FromSeconds(10), + }; + + for (int request = 0; request < 2; request++) // the same connection's fields again, on what the first grew + { + using HttpResponseMessage answer = client.GetAsync($"http://127.0.0.1:{port}/").GetAwaiter().GetResult(); + Assert.Equal(HttpStatusCode.OK, answer.StatusCode); + Assert.Equal(count, answer.Headers.Count(h => h.Key.StartsWith("x-", StringComparison.Ordinal))); + } + }); + } + } + } +} diff --git a/tests/Ioxide.Tests.Unit/Nghttp2OversizedResponseTests.cs b/tests/Ioxide.Tests.Unit/Nghttp2OversizedResponseTests.cs new file mode 100644 index 00000000..2e822003 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/Nghttp2OversizedResponseTests.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; +using System.IO.Pipelines; +using System.Text; +using ioxide.nghttp2; + +namespace Ioxide.Tests; + +/// +/// nghttp2 will not send a header block past 64 KiB, and drops it with nothing on the wire: only a reset tells the peer. +/// +internal static class Nghttp2OversizedResponseTests +{ + private const byte Headers = 0x1, RstStream = 0x3; + + public static void Register(Runner runner) + { + runner.Test("nghttp2: a buffered response with a header block past nghttp2's limit resets its own stream, and the connection serves on", () => + { + using var client = new StrictClient(); + var connection = new Nghttp2Connection(client); + Nghttp2Response oversized = Oversized(); + Task run = connection.RunAsync(request => request.StreamId == 1 ? oversized : Nghttp2Response.Text("ok")); + + client.SendRequests(); // preface and SETTINGS + client.Drain(); + client.Feed([.. Get(1), .. Get(3)]); + List frames = client.Drain(); + + Assert.Equal("1 0x2", Resets(frames)); + Assert.True(!frames.Any(f => f.Type == Headers && f.StreamId == 1), "the oversized response's HEADERS went out"); + Assert.True(frames.Any(f => f.Type == Headers && f.StreamId == 3), "stream 3 was not answered"); + Assert.True(!connection.IsBroken, "one response failed the connection"); + client.Close(run); + connection.Dispose(); + }); + + runner.Test("nghttp2: a streamed response with a header block past nghttp2's limit hears the peer gone at its flush", () => + { + using var client = new StrictClient(); + var connection = new Nghttp2Connection(client); + Nghttp2Response oversized = Oversized(); + var outcome = new TaskCompletionSource(); + Task run = connection.RunAsync(async (request, writer) => + { + if (request.StreamId == 1) + { + writer.WriteHeaders(oversized); + FlushResult flushed = await writer.FlushAsync(); + outcome.SetResult(flushed.IsCompleted ? "peer gone" : "flushed"); + } + }); + + client.SendRequests(); + client.Drain(); + client.Feed(Get(1)); + List frames = client.Drain(); + + Assert.Equal("1 0x2", Resets(frames)); + Assert.True(outcome.Task.IsCompleted, "the flush never returned"); + Assert.Equal("peer gone", outcome.Task.Result); + + client.Feed(Get(3)); + frames = client.Drain(); + Assert.True(frames.Any(f => f.Type == Headers && f.StreamId == 3), "stream 3 was not answered"); + Assert.True(!connection.IsBroken, "one response failed the connection"); + client.Close(run); + connection.Dispose(); + }); + } + + private static Nghttp2Response Oversized() + { + var response = new Nghttp2Response { Status = 200, Body = new byte[1024] }; + byte[] value = Encoding.ASCII.GetBytes(new string('v', 40 * 1024)); + response.Headers.Add("x-a"u8.ToArray(), value); + response.Headers.Add("x-b"u8.ToArray(), value); + return response; + } + + // A GET with END_STREAM: :scheme http, :path /, and an :authority, which nghttp2 requires. + private static byte[] Get(int streamId) + { + byte[] block = [0x82, 0x86, 0x84, 0x01, 9, .. "a.example"u8.ToArray()]; + return Http2FlowControlTests.FrameBytes(Headers, 0x5, streamId, block); + } + + private static string Resets(List frames) => string.Join(", ", frames + .Where(f => f.Type == RstStream) + .Select(f => $"{f.StreamId} 0x{BinaryPrimitives.ReadUInt32BigEndian(f.Payload):x}")); +} diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index f56625c4..81a6b521 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -32,6 +32,7 @@ private static int Main() Nghttp2StreamCloseTests.Register(runner); Http2HeaderListTests.Register(runner); Nghttp2HeaderListTests.Register(runner); + Nghttp2OversizedResponseTests.Register(runner); Http2RequestHeaderTests.Register(runner); Http2RequestReuseTests.Register(runner); Nghttp2RequestReuseTests.Register(runner);