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 <noreply@anthropic.com>
This commit is contained in:
parent
565ce79a4a
commit
c64a2da81d
3 changed files with 147 additions and 1 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" || ',
|
||||
|
|
|
|||
Loading…
Reference in a new issue