Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/clients/ioxide.httpclient/Tls/TlsClientContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ internal static byte[] BuildAlpnWire(string[] protocols)
{
throw new ArgumentException($"ALPN protocol '{protocol}' must be 1..255 bytes.", nameof(protocols));
}
// Each char goes out as one byte, so any other name would be offered as a different protocol.
if (!System.Text.Ascii.IsValid(protocol))
{
throw new ArgumentException($"ALPN protocol '{protocol}' must be ASCII.", nameof(protocols));
}
total += 1 + protocol.Length;
}

Expand Down
5 changes: 5 additions & 0 deletions src/ioxide/Tls/TlsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,11 @@ private static byte[] BuildAlpnWire(string[] protocols)
{
throw new ArgumentException($"ALPN protocol '{protocol}' must be 1..255 bytes.", nameof(protocols));
}
// Each char goes out as one byte, so any other name would be sent as a different protocol.
if (!System.Text.Ascii.IsValid(protocol))
{
throw new ArgumentException($"ALPN protocol '{protocol}' must be ASCII.", nameof(protocols));
}
total += 1 + protocol.Length;
}

Expand Down
5 changes: 5 additions & 0 deletions src/protocols/ioxide.ngtcp2/Engine/QuicEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ private static byte[] AlpnWire(string[]? alpn)
{
throw new ArgumentException($"invalid ALPN token '{proto}'", nameof(alpn));
}
// Encoding.ASCII sends '?' for anything else, so the token would admit a protocol nobody configured.
if (!System.Text.Ascii.IsValid(proto))
{
throw new ArgumentException($"ALPN token '{proto}' must be ASCII", nameof(alpn));
}
wire.Add((byte)bytes.Length);
wire.AddRange(bytes);
}
Expand Down
25 changes: 12 additions & 13 deletions tests/Ioxide.Tests.E2E/Protocols/H3AlpnTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,18 @@ public static void Register(Runner runner)
}, "the ALPN backstop landed only in the pure-C# stack; Nghttp3Connection never reads "
+ "NegotiatedProtocol, so a permissive engine's 'echo' connection is answered 200");

runner.Pending("quic/alpn: a non-ascii allow-list token must not admit a protocol nobody configured", () =>
runner.Test("quic/alpn: a non-ascii allow-list token must not admit a protocol nobody configured", () =>
{
// QuicEngine.AlpnWire encodes each configured token with Encoding.ASCII, whose
// fallback substitutes '?' for anything non-ascii - so ["Ũ2"] goes on the wire as the
// allowlist entry "?2". Two consequences: the configured token itself can never
// negotiate (a client offering the actual bytes of "Ũ2" finds no match), and every
// non-ascii token collapses onto '?', so a client offering the literal "?2" is
// admitted, served, and reads back NegotiatedProtocol == "?2" - a protocol nobody
// configured. The TCP side's BuildAlpnWire has the same shape with a worse symptom
// (UTF-16 units cast to bytes turn "Ũ2" into the real "h2"); the fix on either side
// is to refuse a non-ascii token at construction, like the >255-byte one already is,
// or to encode it faithfully - both make this body pass.
// QuicEngine.AlpnWire encoded each configured token with Encoding.ASCII, whose
// fallback substitutes '?' for anything non-ascii - so ["Ũ2"] went on the wire as the
// allowlist entry "?2". Two consequences: the configured token itself could never
// negotiate (a client offering the actual bytes of "Ũ2" found no match), and every
// non-ascii token collapsed onto '?', so a client offering the literal "?2" was
// admitted, served, and read back NegotiatedProtocol == "?2" - a protocol nobody
// configured. The TCP side's BuildAlpnWire had the same shape with a worse symptom
// (UTF-16 units cast to bytes turn "Ũ2" into the real "h2"). A non-ascii token is now
// refused at construction, like the >255-byte one; encoding it faithfully would also
// make this body pass.
(string certPath, string keyPath) = TestCert.Ensure();
QuicEngine engine;
try
Expand All @@ -205,7 +205,6 @@ public static void Register(Runner runner)
Assert.True(!client.CompleteHandshake(timeoutMs: 5000),
"a client offering '?2' completed the handshake against an allow list of 'Ũ2'");
}
}, "AlpnWire's ASCII '?' substitution puts \"?2\" on the wire for the configured \"Ũ2\", "
+ "and a client offering the literal \"?2\" is admitted and served");
});
}
}
20 changes: 5 additions & 15 deletions tests/Ioxide.Tests.Http/TlsClientPostureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ namespace Ioxide.Tests;
/// while one inside it is served - not that setting it was accepted.
/// </summary>
/// <remarks>
/// Two live defects ride as Pending:
///
/// - BuildAlpnWire casts each UTF-16 unit to a byte, so a non-ASCII protocol name is OFFERED as
/// a different protocol made of its chars' low bytes - the client twin of the server-side
/// defect in TlsService.BuildAlpnWire, proven there today;
/// - HandshakeTimeoutMs is only checked between handshake flights, so the one peer a handshake
/// timeout exists for - accepted the connect, then went silent - is not bounded by it at all.
///
/// Examined without a failing result, stated so they are not re-litigated: MinimumVersion = 0 is
/// accepted (OpenSSL's "no floor"), but this box's system-wide MinProtocol makes the weakened
/// floor unobservable, so no test can discriminate it here; a ServerName over 255 bytes makes the
Expand Down Expand Up @@ -152,13 +144,13 @@ public static void Register(Runner runner)
$"should name the verification failure, got: {body}");
});

runner.Pending("tls client: a non-ascii alpn protocol must not be offered as a different protocol", () =>
runner.Test("tls client: a non-ascii alpn protocol must not be offered as a different protocol", () =>
{
// TlsClientContext.BuildAlpnWire writes (byte)protocol[i] - the LOW BYTE of each UTF-16
// unit. U+0168 has low byte 0x68, 'h', so a client configured to offer ONLY
// "Ũ" + "2" puts the exact bytes "h2" on the wire, the origin selects h2, and
// NegotiatedAlpn reports a protocol the caller never configured. The server side has
// the same cast and the same proven defect; this is the client half.
// "Ũ" + "2" put the exact bytes "h2" on the wire, the origin selected h2, and
// NegotiatedAlpn reported a protocol the caller never configured. The server side had
// the same cast; this is the client half. Create now refuses a non-ASCII name.
string exotic = "Ũ" + "2";

using TlsTestOrigin origin = TlsTestOrigin.Start("h2"); // speaks ONLY h2
Expand Down Expand Up @@ -198,9 +190,7 @@ public static void Register(Runner runner)
Assert.True(!body.EndsWith("hello over h2"),
"a client configured to offer only 'U+0168 2' completed a conversation the origin "
+ $"negotiated as h2; body: {body}, origin saw alpn={origin.LastAlpn ?? "none"}");
}, "BuildAlpnWire casts UTF-16 units to bytes, so the configured \"\\u0168 2\" goes on the "
+ "wire as the bytes 'h2' and the client negotiates - and NegotiatedAlpn reports - a "
+ "protocol nobody configured");
});

runner.Pending("tls client: HandshakeTimeoutMs bounds a handshake whose peer accepted and went silent", () =>
{
Expand Down
16 changes: 6 additions & 10 deletions tests/Ioxide.Tests.Tls/AlpnNegotiationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ namespace Ioxide.Tests;
/// negotiation: what the client was told, and what a handler would branch on to pick an h2 loop
/// over an h1 one. The select loop is additionally pinned by reflection for the malformed offers
/// OpenSSL never forwards - that test says why end-to-end cannot deliver them.
///
/// One live defect rides as Pending: BuildAlpnWire casts each UTF-16 unit to a byte, so a
/// non-ASCII protocol name goes on the wire as a DIFFERENT protocol made of its chars' low bytes.
/// </remarks>
internal static class AlpnNegotiationTests
{
Expand Down Expand Up @@ -140,14 +137,15 @@ public static void Register(Runner runner)

RegisterSelectLoopBounds(runner);

runner.Pending("alpn: a non-ascii protocol name must not negotiate as a different protocol", () =>
runner.Test("alpn: a non-ascii protocol name must not negotiate as a different protocol", () =>
{
// BuildAlpnWire writes (byte)protocol[i] - the LOW BYTE of each UTF-16 unit. U+0168
// has low byte 0x68, which is 'h', so a server configured to speak only "\u0168" + "2"
// puts the exact bytes "h2" on the wire and ACKs any client offering h2. Two distinct
// protocol identifiers compare equal, the client is told h2, and CaptureAlpn records
// put the exact bytes "h2" on the wire and ACKed any client offering h2. Two distinct
// protocol identifiers compared equal, the client was told h2, and CaptureAlpn recorded
// h2 - a protocol the operator never listed. (The same cast is why the SAME non-ASCII
// string configured on both sides fails to match: SslApplicationProtocol sends UTF-8.)
// string configured on both sides failed to match: SslApplicationProtocol sends UTF-8.)
// BuildAlpnWire now refuses a non-ASCII name, so the start below is refused.
string exotic = "\u0168" + "2"; // two chars whose low bytes are 'h','2'

(string cert, string key) = TestCert.Ensure();
Expand Down Expand Up @@ -186,9 +184,7 @@ public static void Register(Runner runner)
Assert.True(negotiated != "h2",
"a server configured to speak only 'U+0168 2' ACKed h2 - the char-to-byte cast in "
+ "BuildAlpnWire makes two distinct protocol identifiers compare equal");
}, "BuildAlpnWire casts UTF-16 units to bytes, so the configured \"\\u01682\" goes on the wire "
+ "as the bytes 'h2' and the server negotiates - and CaptureAlpn records - a protocol "
+ "nobody configured");
});
}

/// <summary>
Expand Down
Loading