|
1 | 1 | using ioxide; |
| 2 | +using ioxide.nghttp3; |
2 | 3 | using ioxide.ngtcp2; |
3 | 4 |
|
4 | 5 | namespace Ioxide.Tests; |
5 | 6 |
|
6 | 7 | /// <summary> |
7 | | -/// ALPN as HTTP/3 requires it, and what the negotiated protocol reads back as. |
| 8 | +/// ALPN as HTTP/3 requires it, and what the negotiated protocol reads back as. RFC 9001 section |
| 9 | +/// 8.1 makes ALPN mandatory for QUIC (no mutual protocol = no_application_protocol, during the |
| 10 | +/// handshake); RFC 9114 section 3.1 makes "h3" the token an HTTP/3 server may serve. |
8 | 11 | /// </summary> |
9 | 12 | /// <remarks> |
10 | | -/// Reserved for a review pass whose deliverable is a FAILING test. See tests/README.md: a defect |
11 | | -/// that has been reproduced is committed as <c>runner.Pending</c> - it reports PEND while it still |
12 | | -/// fails, and fails the run the moment it starts passing. |
| 13 | +/// Two behaviours in this area were examined and could NOT be driven from this suite, so they are |
| 14 | +/// recorded here rather than half-tested: |
13 | 15 | /// |
14 | | -/// Empty is a legitimate outcome. It means the area was examined and nothing was found that could |
15 | | -/// be made to fail, which is worth more than a test that passes for reasons nobody established. |
| 16 | +/// - Server preference order with a multi-token offer (the shim's iq_on_client_hello walks the |
| 17 | +/// allowlist on the outside, so the server's order decides, like the TCP side's |
| 18 | +/// AlpnNegotiationTests pins). The shim's client entry points hand picotls exactly ONE token - |
| 19 | +/// a single iovec, count = 1 - so no in-tree client can offer several protocols at once. |
| 20 | +/// |
| 21 | +/// - The negotiated token reading back as nothing when it exceeds the 64-byte read-back buffer |
| 22 | +/// (iq_conn_get_alpn returns 0 when the token does not fit the buffer |
| 23 | +/// QuicEngineConnection.HandshakeCompletedOnce hands it). A legal ALPN token may be 255 bytes, |
| 24 | +/// but the shim's client stores its offer in a char[64] via snprintf, truncating it to 63 - so |
| 25 | +/// the shortest token that would trip the server's cap cannot be offered from here. The 63-byte |
| 26 | +/// test below pins the longest reachable token instead. |
16 | 27 | /// </remarks> |
17 | 28 | internal static class H3AlpnTests |
18 | 29 | { |
19 | 30 | public static void Register(Runner runner) |
20 | 31 | { |
| 32 | + runner.Test("quic/alpn: control - a pinned engine serves an h3 offer and the handler reads back 'h3'", () => |
| 33 | + { |
| 34 | + // The control for every refusal below: the same engine shape, the same handler, the |
| 35 | + // one offer an HTTP/3 server may accept - and it serves. Also the only place the |
| 36 | + // SERVER-side NegotiatedProtocol value is asserted: the pure-C# stack consumes it in |
| 37 | + // its backstop, but nothing else pins that the shim's read-back (iq_conn_get_alpn) |
| 38 | + // surfaces the very token the client offered. |
| 39 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 40 | + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); |
| 41 | + |
| 42 | + (_, int udpPort) = TestServer.StartDatagram( |
| 43 | + onDatagram: null, |
| 44 | + quicFactory: engine.CreateFactory(), |
| 45 | + quicHandle: (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( |
| 46 | + _ => Nghttp3Response.Text($"alpn={conn.NegotiatedProtocol ?? "(none)"}"))); |
| 47 | + |
| 48 | + using var client = new H3TestClient("127.0.0.1", udpPort); |
| 49 | + client.Connect(); |
| 50 | + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); |
| 51 | + |
| 52 | + (int status, string body) = client.Get("/alpn", timeoutMs: 5000); |
| 53 | + Assert.Equal(200, status); |
| 54 | + Assert.Equal("alpn=h3", body); |
| 55 | + }); |
| 56 | + |
| 57 | + runner.Test("quic/alpn: a pinned engine refuses a no-overlap offer during the handshake, with a close", () => |
| 58 | + { |
| 59 | + // RFC 9001 section 8.1: no mutual protocol fails the handshake with |
| 60 | + // no_application_protocol. The engine-side allowlist is the real fix for serving h3 |
| 61 | + // to clients that never claimed it, and every h3 test site now pins ["h3"] - but the |
| 62 | + // refusal itself was one test deep and asserted only "not served". This pins the |
| 63 | + // stronger half: the handshake never completes, and the refusal ARRIVES as a close. |
| 64 | + // PeerClosed is the load-bearing assert - a server that silently dropped the |
| 65 | + // connection would also fail CompleteHandshake, by timeout, and a hang is not a |
| 66 | + // refusal (it is also not the alert RFC 9001 requires). |
| 67 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 68 | + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); |
| 69 | + |
| 70 | + (_, int udpPort) = TestServer.StartDatagram( |
| 71 | + onDatagram: null, |
| 72 | + quicFactory: engine.CreateFactory(), |
| 73 | + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( |
| 74 | + static _ => Nghttp3Response.Text("must never serve"))); |
| 75 | + |
| 76 | + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "echo" }; |
| 77 | + client.Connect(); |
| 78 | + bool done = client.CompleteHandshake(timeoutMs: 5000); |
| 79 | + Assert.True(!done, "a pinned engine must not complete a handshake with no ALPN overlap"); |
| 80 | + Assert.True(client.PeerClosed, |
| 81 | + "the refusal must arrive as a close during the handshake - a timeout is a hang, not a refusal"); |
| 82 | + }); |
| 83 | + |
| 84 | + runner.Test("quic/alpn: a pinned engine refuses a client that offered no ALPN at all", () => |
| 85 | + { |
| 86 | + // The other half of RFC 9001 section 8.1: a client that offers NOTHING. An empty Alpn |
| 87 | + // makes the shim's client omit the extension entirely (it only hands picotls a list |
| 88 | + // for a non-empty token), and a pinned server must treat that as no overlap - not as |
| 89 | + // "nothing to check". The permissive engine's documented default is to confirm even |
| 90 | + // this; pinning is what closes it, so the pinned refusal is the behaviour to hold. |
| 91 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 92 | + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]); |
| 93 | + |
| 94 | + (_, int udpPort) = TestServer.StartDatagram( |
| 95 | + onDatagram: null, |
| 96 | + quicFactory: engine.CreateFactory(), |
| 97 | + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( |
| 98 | + static _ => Nghttp3Response.Text("must never serve"))); |
| 99 | + |
| 100 | + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "" }; |
| 101 | + client.Connect(); |
| 102 | + bool done = client.CompleteHandshake(timeoutMs: 5000); |
| 103 | + Assert.True(!done, "RFC 9001 8.1: a pinned engine must refuse a client that offered no ALPN"); |
| 104 | + Assert.True(client.PeerClosed, |
| 105 | + "the refusal must arrive as a close during the handshake - a timeout is a hang, not a refusal"); |
| 106 | + }); |
| 107 | + |
| 108 | + runner.Test("quic/alpn: a 63-byte token - the longest the harness can offer - reads back whole", () => |
| 109 | + { |
| 110 | + // The negotiated token reads back through a 64-byte buffer, and iq_conn_get_alpn |
| 111 | + // answers 0 - "no protocol" - for anything that does not fit, so a shrunk buffer |
| 112 | + // would not fail loudly: it would report a legal negotiated token as none at all. |
| 113 | + // 63 bytes is the longest offer the harness client can make (its own char[64] + |
| 114 | + // snprintf truncation - see the file remarks), which makes it the boundary this |
| 115 | + // suite can hold: the whole token, not empty, not clipped. |
| 116 | + // |
| 117 | + // Recorded at handshake completion rather than through a served response, so this |
| 118 | + // stays true even once the nghttp3 layer learns to refuse non-h3 connections. |
| 119 | + string big = new string('a', 63); |
| 120 | + var recorded = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously); |
| 121 | + |
| 122 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 123 | + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); // permissive: confirms the offer |
| 124 | + |
| 125 | + (_, int udpPort) = TestServer.StartDatagram( |
| 126 | + onDatagram: null, |
| 127 | + quicFactory: engine.CreateFactory(), |
| 128 | + quicHandle: (_, conn) => |
| 129 | + { |
| 130 | + ((QuicEngineConnection)conn).HandshakeCompleted = |
| 131 | + () => recorded.TrySetResult(conn.NegotiatedProtocol); |
| 132 | + return new Nghttp3Connection(conn).RunBufferedAsync( |
| 133 | + static _ => Nghttp3Response.Text("ok")); |
| 134 | + }); |
| 135 | + |
| 136 | + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = big }; |
| 137 | + client.Connect(); |
| 138 | + Assert.True(client.CompleteHandshake(timeoutMs: 5000), "handshake did not complete"); |
| 139 | + |
| 140 | + Assert.True(recorded.Task.Wait(5000), "the server never reported handshake completion"); |
| 141 | + Assert.Equal(big, recorded.Task.Result); |
| 142 | + }); |
| 143 | + |
| 144 | + runner.Pending("h3/nghttp3: a connection that did not negotiate h3 is not served", () => |
| 145 | + { |
| 146 | + // The mirror of Http3Tests' "a connection that did not negotiate h3 is not served", |
| 147 | + // on the OTHER stack. The backstop landed only in the pure-C# layer |
| 148 | + // (Http3Connection.RunCoreAsync checks NegotiatedProtocol once the control stream is |
| 149 | + // up); Nghttp3Connection never reads it, so on an engine built without an allowlist - |
| 150 | + // the constructor's documented default and its own doc example - it answers HTTP/3 on |
| 151 | + // a connection that negotiated "echo", a protocol the client actually asked for and |
| 152 | + // is entitled to believe it got. |
| 153 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 154 | + using var engine = new QuicEngine(certPath, keyPath, cidLength: 8); // permissive, on purpose |
| 155 | + |
| 156 | + (_, int udpPort) = TestServer.StartDatagram( |
| 157 | + onDatagram: null, |
| 158 | + quicFactory: engine.CreateFactory(), |
| 159 | + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( |
| 160 | + static _ => Nghttp3Response.Text("served-by-nghttp3"))); |
| 161 | + |
| 162 | + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "echo" }; |
| 163 | + client.Connect(); |
| 164 | + Assert.True(client.CompleteHandshake(timeoutMs: 5000), |
| 165 | + "the permissive engine should still complete the handshake - that is the point"); |
| 166 | + |
| 167 | + (int status, string body) = client.Get("/nope", timeoutMs: 3000); |
| 168 | + Assert.True(status != 200, $"an h3 handler must not serve a non-h3 connection, got {status} '{body}'"); |
| 169 | + }, "the ALPN backstop landed only in the pure-C# stack; Nghttp3Connection never reads " |
| 170 | + + "NegotiatedProtocol, so a permissive engine's 'echo' connection is answered 200"); |
| 171 | + |
| 172 | + runner.Pending("quic/alpn: a non-ascii allow-list token must not admit a protocol nobody configured", () => |
| 173 | + { |
| 174 | + // QuicEngine.AlpnWire encodes each configured token with Encoding.ASCII, whose |
| 175 | + // fallback substitutes '?' for anything non-ascii - so ["Ũ2"] goes on the wire as the |
| 176 | + // allowlist entry "?2". Two consequences: the configured token itself can never |
| 177 | + // negotiate (a client offering the actual bytes of "Ũ2" finds no match), and every |
| 178 | + // non-ascii token collapses onto '?', so a client offering the literal "?2" is |
| 179 | + // admitted, served, and reads back NegotiatedProtocol == "?2" - a protocol nobody |
| 180 | + // configured. The TCP side's BuildAlpnWire has the same shape with a worse symptom |
| 181 | + // (UTF-16 units cast to bytes turn "Ũ2" into the real "h2"); the fix on either side |
| 182 | + // is to refuse a non-ascii token at construction, like the >255-byte one already is, |
| 183 | + // or to encode it faithfully - both make this body pass. |
| 184 | + (string certPath, string keyPath) = TestCert.Ensure(); |
| 185 | + QuicEngine engine; |
| 186 | + try |
| 187 | + { |
| 188 | + engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["Ũ2"]); |
| 189 | + } |
| 190 | + catch (ArgumentException) |
| 191 | + { |
| 192 | + return; // refused at configuration - the defect is gone |
| 193 | + } |
| 194 | + |
| 195 | + using (engine) |
| 196 | + { |
| 197 | + (_, int udpPort) = TestServer.StartDatagram( |
| 198 | + onDatagram: null, |
| 199 | + quicFactory: engine.CreateFactory(), |
| 200 | + quicHandle: static (_, conn) => new Nghttp3Connection(conn).RunBufferedAsync( |
| 201 | + static _ => Nghttp3Response.Text("must never serve"))); |
| 202 | + |
| 203 | + using var client = new H3TestClient("127.0.0.1", udpPort) { Alpn = "?2" }; |
| 204 | + client.Connect(); |
| 205 | + Assert.True(!client.CompleteHandshake(timeoutMs: 5000), |
| 206 | + "a client offering '?2' completed the handshake against an allow list of 'Ũ2'"); |
| 207 | + } |
| 208 | + }, "AlpnWire's ASCII '?' substitution puts \"?2\" on the wire for the configured \"Ũ2\", " |
| 209 | + + "and a client offering the literal \"?2\" is admitted and served"); |
21 | 210 | } |
22 | 211 | } |
0 commit comments