Skip to content

Commit 82ba4ef

Browse files
authored
tests: land the review round's reproductions, with the refuted claims turned into what is true (#197)
The 24-agent review round produced 20 test files that were never committed, because 11 of their 31 reproductions had been refuted by an adversarial pass and shipping them would have put two mutually exclusive assertions in the same suite - one of them asserts the opposite of a test that passes today. Leaving all 20 out cost the 77 PASSING tests in them, which is the larger number: controls, matrices and ordinary coverage the agents wrote alongside their claims. So the refuted 11 are converted rather than deleted. Each drove a real scenario that was already wired up; what was wrong was the conclusion. Each now pins the behaviour that was verified correct and records why the claim did not hold: resume a ticket issued before a rotation is REFUSED after it, and the client still served. Ticket keys are per SSL_CTX; nginx, Apache and HAProxy retire them on reload too, and the test below it depends on that - a ticket outliving a rotation would carry the old verify verdict past the new anchors. truncation the pipe reports both endings alike and the SESSION tells them apart. Faulting the cut one would fault every client that merely disposes its SslStream. identity a CN the decoder refuses is not named, even where the rendered subject cannot tell it apart - PeerSubject is documented for people, PeerCommonName is what authorizes. mtls/quic a DN or CN too long to record is reported as NO name, never a prefix. Fails closed on purpose: a prefix can belong to someone else. quic/fault a throwing HandshakeCompleted is logged and the connection keeps serving, which is this runtime's policy for user-code faults everywhere else. http3 a body cut mid-frame kills the connection with H3_FRAME_ERROR and the reader signals an ordinary end - the contract all three body readers document identically. parity omitting the host table is refused on QUIC and applied on TCP, pinned as a KNOWN DIVERGENCE. Neither side is a bug; TCP's is documented and pinned green, so resolving it is a deliberate breaking change rather than a fix, and this makes sure nobody resolves it by accident. lifetime deleted - the file already had a passing test for the only true part. Two were better answered by fixing the code than by rewriting the test, which both the refuting agent and the commit auditor independently recommended: TlsService.Start now range-checks MinProtocolVersion and HandshakeTimeoutMs. Both resolved to something plausible rather than failing - an undefined version through a not-Tls13 ternary to the TLS 1.2 floor, and a negative timeout to no handshake sweep at all, silently removing the only bound on a peer that connects and says nothing. No config binder validates an enum, so neither needed a cast to arrive. Two identity doc comments were corrected to match: null from PeerSubject/PeerCommonName is a refusal, not "the peer offered none". The 19 surviving reproductions stay as Pending - they report PEND and fail the run if they start passing. 419 passed, 0 failed, 20 pending, across all six suites.
1 parent 60b36a0 commit 82ba4ef

23 files changed

Lines changed: 8197 additions & 146 deletions

src/ioxide/Tls/TlsService.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ private static byte[] Fold(string host)
205205
/// </param>
206206
public static TlsService Start(Reactor reactor, TlsOptions options, bool register = true)
207207
{
208+
// Scalars first, because the checks below reason about COMBINATIONS and a value outside its
209+
// own domain makes that reasoning meaningless. Both of these resolve to something plausible
210+
// rather than failing, which is the shape worth refusing: an undefined version maps to the
211+
// TLS 1.2 floor through a not-Tls13 ternary, and a negative timeout disables the handshake
212+
// sweep entirely because both readers guard on "> 0" - so the one bound on a peer that
213+
// connects and then says nothing is silently off. No config binder validates an enum
214+
// (Enum.Parse<TlsProtocolVersion>("3") succeeds), so neither value needs a cast to arrive.
215+
if (!Enum.IsDefined(options.MinProtocolVersion))
216+
{
217+
throw new ArgumentException(
218+
$"MinProtocolVersion is {(int)options.MinProtocolVersion}, which is not one of "
219+
+ "Default, Tls12 or Tls13. Name the floor you want rather than leaving it to be "
220+
+ "resolved.", nameof(options));
221+
}
222+
223+
if (options.HandshakeTimeoutMs < 0)
224+
{
225+
throw new ArgumentException(
226+
$"HandshakeTimeoutMs is {options.HandshakeTimeoutMs}. Zero disables the handshake "
227+
+ "sweep; a negative value would disable it too, which is worth saying rather than "
228+
+ "arriving at by accident.", nameof(options));
229+
}
230+
208231
// RX alone cannot be programmed: the handoff shares the TCP_ULP that EnableTx installs.
209232
// Refuse loudly rather than silently serving the userspace path the caller opted out of.
210233
if (options.KernelRx && !options.KernelTx)

src/protocols/ioxide.ngtcp2/Connection/QuicEngineConnection.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ public unsafe partial class QuicEngineConnection : QuicConnection
3939
/// whose organisation is <c>Acme\/CN=admin.internal</c> satisfies a
4040
/// <c>Contains("/CN=admin.internal")</c> check while being a different principal.
4141
/// <see cref="PeerCommonName"/> is the value to compare instead.
42+
///
43+
/// Null also when the DN does not fit the 1024 bytes the shim records, which is a refusal
44+
/// rather than an omission: a truncated DN is plausible, comparable, and can equal a DIFFERENT
45+
/// principal's prefix, so no name is reported instead of a partial one. Nothing here ever
46+
/// hands back a shortened identity.
4247
/// </remarks>
4348
public string? PeerSubject
4449
{
@@ -65,8 +70,11 @@ public string? PeerSubject
6570
/// with <see cref="StringComparison.Ordinal"/>.
6671
///
6772
/// Null when there was no validated certificate, when the subject carries no CN (legitimate:
68-
/// modern certificates identify by subjectAltName), or when the CN is empty or contains an
69-
/// embedded NUL, which is a name built to be read differently by different consumers.
73+
/// modern certificates identify by subjectAltName), when the CN is empty or contains an
74+
/// embedded NUL - a name built to be read differently by different consumers - or when it
75+
/// exceeds the 256 bytes recorded for it, which is four times RFC 5280's ub-common-name of 64.
76+
/// Every one of those is a refusal rather than an omission: the accessor never reports a name
77+
/// it had to shorten, because a prefix can belong to someone else.
7078
/// </summary>
7179
public string? PeerCommonName
7280
{
Lines changed: 195 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,211 @@
11
using ioxide;
2+
using ioxide.nghttp3;
23
using ioxide.ngtcp2;
34

45
namespace Ioxide.Tests;
56

67
/// <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.
811
/// </summary>
912
/// <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:
1315
///
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.
1627
/// </remarks>
1728
internal static class H3AlpnTests
1829
{
1930
public static void Register(Runner runner)
2031
{
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");
21210
}
22211
}

0 commit comments

Comments
 (0)