From c64a2da81d65ddbbfa56ebaff62499416dc0f415 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 18:29:13 +0200 Subject: [PATCH] Refuse the URL-repaired spellings in the protection gate too `isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before `URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven spellings `System.Uri` refuses outright. Every one of them answered protected here and unprotected in the C# reference, which is the dangerous direction: the channel-protection gate opened on a route `IsProtected` would have closed. `wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and `wss://host\r\n/m` a CR/LF, all of which `URL` deletes. Soul's 71 raw-shape spellings go in as a table with the answer the C# `IsProtected` gave for each on 2026-09-16, and the test asserts the invariant rather than equality: TypeScript may never call an endpoint protected that C# refuses. Eleven of the 71 now differ the safe way round, C# protected and this not, and they are pinned by name rather than excluded, so neither a twelfth joining them nor one of them flipping passes quietly. Two throw in `URL` before any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser CultMesh route. The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving `isLoopbackEndpoint`'s in place, is killed by that test. Co-Authored-By: Claude Fable 5.1 --- packages/cultnet-ts/src/cultmesh-authority.ts | 22 +++- .../test/cultmesh-authority.test.ts | 118 ++++++++++++++++++ scripts/mutate-cultmesh-authority.mjs | 8 ++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index b9681c71..10c9d187 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -361,8 +361,28 @@ export function isLoopbackEndpoint(value: string): boolean { } } -/** The C# rule (`CultMeshAuthorityProof.cs`, `IsProtected`): wss, https, or any scheme containing "quic". */ +/** + * The C# rule (`CultMeshAuthorityProof.cs`, `IsProtected`): wss, https, or any + * scheme containing "quic", over a string `System.Uri` parses at all. + * + * `refusedByCSharpUriShape` runs first here for the same reason it runs first in + * `isLoopbackEndpoint`, and it matters more: `URL` repairs eleven spellings + * `System.Uri` refuses outright, and every one of them would be *protected* here + * and unprotected in C#. `wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and + * `wss:///host` have no authority for `System.Uri` to accept; `wss://host\path` + * and `wss://host:8443\` carry a backslash; `wss://ho\tst/m`, `wss://host\t/m`, + * `quic://ho\tst` carry a tab and `wss://host\r\n/m` a CR/LF, which `URL` + * deletes. Letting any of them answer true would clear the channel-protection + * gate on a route C# refuses. + * + * Two spellings of Soul's 71 still disagree, both the safe way round: C# calls + * them protected and this answers false, so a route is refused rather than + * admitted. `wss://[::1%eth0]/m` (the IPv6 zone id) and `wss://host /m` + * (a non-breaking space in the host) both throw in `URL`, so they answered + * false before this pre-check existed too. Neither is dialable from a browser. + */ export function isProtectedEndpoint(value: string): boolean { + if (refusedByCSharpUriShape(value)) return false; let scheme: string; try { scheme = new URL(value).protocol.slice(0, -1).toLowerCase(); diff --git a/packages/cultnet-ts/test/cultmesh-authority.test.ts b/packages/cultnet-ts/test/cultmesh-authority.test.ts index f5e61b80..3017300f 100644 --- a/packages/cultnet-ts/test/cultmesh-authority.test.ts +++ b/packages/cultnet-ts/test/cultmesh-authority.test.ts @@ -311,6 +311,124 @@ test("isProtectedEndpoint is the C# rule: wss, https, or any scheme containing q } }); +// Every raw-shape spelling Soul put to both sides on 2026-09-16, with the answer +// `IsProtected` gave through `CultMeshAuthorityTrustPolicy` as the expectation. +// The list is Soul's 71 verbatim, duplicate included. +const CSHARP_PROTECTED_ANSWERS: readonly (readonly [string, boolean])[] = [ + ["ws:x", false], + ["wss:x", false], + ["wss:/host", false], + ["wss:///host", false], + ["wss://host:8443\\", false], + ["wss://host\\path", false], + ["wss:\\\\host/m", true], + ["wss://host./m", true], + ["wss://\uff48\uff4f\uff53\uff54/m", true], + ["QUIC://host/m", true], + ["Quic+x://host/m", true], + ["cultmesh-state+quic://h:4433/?c=1", true], + ["wss://ho\tst/m", false], + ["wss://host\t/m", false], + ["\twss://host/m", true], + [" wss://host/m", true], + ["wss://host/m ", true], + ["wss://host/m\n", true], + ["wss://host:99999/m", false], + ["wss://[::1%eth0]/m", true], + ["wss://user:pw@host/m", true], + ["https://host/m", true], + ["HTTPS://host", true], + ["ws://host/m", false], + ["WS://host/m", false], + ["http://host/m", false], + ["quic:host", true], + ["quic:", true], + ["wss:", false], + ["wss://", false], + ["wss:///", false], + ["quic+wss://host", true], + ["x-quic://host", true], + ["xquicx://host", true], + ["wss ://host/m", false], + ["w ss://host/m", false], + ["wss://host:abc/m", false], + ["wss://\uff11\uff12\uff17.0.0.1/m", true], + ["wss://127.0.0.1./m", true], + ["not a url", false], + ["", false], + [" ", false], + ["wss://host?x=1", true], + ["wss://host#f", true], + ["wss://[::1]/m", true], + ["wss://[::1", false], + ["wss://host/m\u0000", true], + ["wss://host/\u0085m", true], + ["\u0085wss://host/m", false], + ["\ufeffwss://host/m", false], + ["ws://127.0.0.1\\m", false], + ["ws:127.0.0.1", false], + ["ws:/127.0.0.1", false], + ["ws:///127.0.0.1", false], + ["ws://127.0.0.1./m", false], + ["ws://\uff4c\uff4f\uff43\uff41\uff4c\uff48\uff4f\uff53\uff54/m", false], + ["ws://localhost\t/m", false], + ["wss://host:8443\\", false], + ["wss:host", false], + ["wss:host:8443", false], + ["wss://host:8443/\\", true], + ["wss://ho st/m", false], + ["wss://host%20/m", false], + ["wss://host\u00a0/m", true], + ["wss://host\r\n/m", false], + ["wss:// host/m", false], + ["wss://host /m", false], + ["quic://ho\tst", false], + ["QUIC:\\\\host", true], + ["ws://127.0.0.1:1/m\t", false], + ["wss://xn--80ak6aa92e.com/m", true], +]; + +// The eleven of the 71 where the two sides still differ, every one of them the +// safe way round: C# calls the endpoint protected and TypeScript does not, so a +// route is refused rather than admitted. `refusedByCSharpUriShape` is stricter +// than `System.Uri.TryCreate` on purpose — it also refuses the host repairs that +// would make `isLoopbackEndpoint` lie — and a zone id and a non-breaking-space +// host throw in `URL` before any rule of ours runs. None of them is dialable as +// a browser CultMesh route. Pinned exactly, not merely excluded: if one of these +// starts answering true, or a twelfth spelling joins them, this fails. +const STRICTER_THAN_CSHARP: readonly string[] = [ + "wss:\\\\host/m", + "wss://host./m", + "wss://\uff48\uff4f\uff53\uff54/m", + "wss://[::1%eth0]/m", + "quic:host", + "quic:", + "wss://\uff11\uff12\uff17.0.0.1/m", + "wss://127.0.0.1./m", + "wss://host:8443/\\", + "wss://host\u00a0/m", + "QUIC:\\\\host", +]; + +test("isProtectedEndpoint is never more permissive than the C# IsProtected, over every raw-shape spelling Soul probed", () => { + assert.equal(CSHARP_PROTECTED_ANSWERS.length, 71); + const stricter = new Set(STRICTER_THAN_CSHARP); + // The invariant that matters: TypeScript must never call an endpoint protected + // that C# refuses, or the channel-protection gate opens on a route the + // reference would have closed. `URL` repairs eleven spellings `System.Uri` + // refuses outright, and every one of them used to land on the wrong side here. + const morePermissive: string[] = []; + const unexpectedlyStricter: string[] = []; + for (const [endpoint, csharp] of CSHARP_PROTECTED_ANSWERS) { + const ours = isProtectedEndpoint(endpoint); + if (ours && !csharp) morePermissive.push(JSON.stringify(endpoint)); + if (!ours && csharp && !stricter.has(endpoint)) unexpectedlyStricter.push(JSON.stringify(endpoint)); + if (stricter.has(endpoint)) assert.equal(ours, false, `${JSON.stringify(endpoint)} is listed as stricter`); + } + assert.deepEqual(morePermissive, []); + assert.deepEqual(unexpectedlyStricter, []); +}); + // Every endpoint Soul put to both sides on 2026-09-16, with the answer // `System.Uri.IsLoopback` gave through `CultMeshAuthorityTrustPolicy` as the // expectation. Six of the probe's 88 are left out and named in the doc comment diff --git a/scripts/mutate-cultmesh-authority.mjs b/scripts/mutate-cultmesh-authority.mjs index 92e4bf53..1e3e1b32 100644 --- a/scripts/mutate-cultmesh-authority.mjs +++ b/scripts/mutate-cultmesh-authority.mjs @@ -233,6 +233,14 @@ const mutations = [ old: 'scheme.includes("quic")', new: 'scheme === "quic"', }, + { + // Only `isProtectedEndpoint`'s copy: `isLoopbackEndpoint` keeps its own, so + // this pins that the protection gate refuses the spellings `URL` repairs + // rather than inheriting the answer from the loopback rule. + rule: "isProtectedEndpoint refuses the raw shapes System.Uri refuses", + old: " if (refusedByCSharpUriShape(value)) return false;\n let scheme: string;\n", + new: " let scheme: string;\n", + }, { rule: "protected scheme: https", old: 'scheme === "https" || ',