diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index 9e28bad7..c5fd2c5b 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -296,6 +296,40 @@ function ownedBytes(view: Uint8Array): ArrayBuffer { return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; } +/** + * `System.Uri` refuses spellings that WHATWG `URL` repairs, and a repaired + * spelling would be loopback here and not in C#. This is that refusal, on the + * raw string, before `URL` sees it: the scheme must be followed by `//` and a + * non-empty authority, the string carries no backslash, and the host is ASCII, + * free of whitespace, and does not end in a dot. + * + * Each clause answers one spelling Soul found `System.Uri` refusing and `URL` + * accepting on 2026-09-16: `ws://127.0.0.1.` (the trailing dot, which `URL` + * drops), `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1` (the missing or + * empty authority, which `URL` infers), `ws://127.0.0.1\mesh` (the backslash, + * which `URL` reads as a path separator), `ws://127.0.0.1` and + * `ws://localhost` (fullwidth forms, which `URL` folds to ASCII), and + * `ws://localhost\t/mesh` (the tab, which `URL` deletes). + * + * Leading and trailing C0 control characters and spaces around the whole + * endpoint are stripped first, because `URL` strips them and `System.Uri` + * accepts them: " ws://127.0.0.1/mesh" is loopback to both. + */ +function refusedByCSharpUriShape(value: string): boolean { + const raw = value.replace(/^[- ]+|[- ]+$/g, ""); + if (raw.includes("\\")) return true; + const shape = /^[A-Za-z][A-Za-z0-9+\-.]*:\/\/([^/?#]+)/.exec(raw); + if (!shape) return true; + const authority = shape[1]!; + const afterUserInfo = authority.slice(authority.lastIndexOf("@") + 1); + const host = afterUserInfo.startsWith("[") + ? afterUserInfo.slice(0, afterUserInfo.indexOf("]") + 1) + : afterUserInfo.split(":")[0]!; + if (host.length === 0) return true; + // eslint-disable-next-line no-control-regex + return /[^!-~]/.test(host) || host.endsWith("."); +} + /** * The C# rule (`CultMeshAuthorityProof.cs`, `IsLoopback`): `System.Uri.IsLoopback`, * which is any `127.0.0.0/8` IPv4 host, `::1`, the IPv4-mapped `::ffff:127.0.0.1` @@ -303,8 +337,21 @@ function ownedBytes(view: Uint8Array): ArrayBuffer { * WHATWG `URL` already normalises `127.1`, `0x7f000001` and `2130706433` to * `127.0.0.1` and serialises the mapped form as `::ffff:7f00:1`, as `System.Uri` * does on its side. `localhost.` is not loopback on either side. + * + * This is not `System.Uri.IsLoopback`, and the spellings where it still differs + * are named rather than claimed away. `URL` repairs seven spellings `System.Uri` + * refuses, and `refusedByCSharpUriShape` refuses those first. Two divergences + * remain, both unreachable from a browser CultMesh route: + * + * - An IPv6 zone id (`ws://[::1%eth0]`, `%25`-escaped or not) is loopback to + * `System.Uri` and throws in `URL`, so this answers false. A zone id is a + * host-local interface name; it cannot be dialled from a browser socket. + * - `file://localhost/mesh` and `mailto:localhost` are loopback to `System.Uri` + * and have no host in `URL`, so this answers false. Neither is a CultMesh + * route: `openSocket` refuses any scheme but `ws:` and `wss:`. */ export function isLoopbackEndpoint(value: string): boolean { + if (refusedByCSharpUriShape(value)) return false; try { const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); return host === "localhost" || host === "loopback" || host === "::1" || host === "::ffff:7f00:1" || diff --git a/packages/cultnet-ts/test/cultmesh-authority.test.ts b/packages/cultnet-ts/test/cultmesh-authority.test.ts index a0820628..f5e61b80 100644 --- a/packages/cultnet-ts/test/cultmesh-authority.test.ts +++ b/packages/cultnet-ts/test/cultmesh-authority.test.ts @@ -311,22 +311,105 @@ test("isProtectedEndpoint is the C# rule: wss, https, or any scheme containing q } }); -// The accepted and refused hosts here are what `System.Uri.IsLoopback` answered -// through `CultMeshAuthorityTrustPolicy` on 2026-09-16; keep them in step. -test("isLoopbackEndpoint is the C# System.Uri.IsLoopback set", () => { - for (const loopback of [ - "ws://localhost:4050/mesh", "wss://LOCALHOST/mesh", "ws://loopback:4050/mesh", "ws://LoopBack:4050/mesh", - "ws://127.0.0.1:4050/mesh", "ws://127.0.0.2/mesh", "ws://127.255.255.254/mesh", "ws://127.1/mesh", "ws://0x7f000001/mesh", "ws://2130706433/mesh", - "ws://[::1]:4050/mesh", "ws://[0:0:0:0:0:0:0:1]:4050/mesh", "ws://[::ffff:127.0.0.1]/mesh", "ws://[::ffff:7f00:1]/mesh", - ]) { - assert.equal(isLoopbackEndpoint(loopback), true, loopback); - } - for (const remote of [ - "ws://192.0.2.10:4050/mesh", "wss://provider.example/mesh", "ws://[::ffff:127.0.0.2]/mesh", "ws://localhost./mesh", - "ws://localhost.localdomain/mesh", "ws://127.example/mesh", "ws://0.0.0.0/mesh", "ws://[::]/mesh", "not a url", - ]) { - assert.equal(isLoopbackEndpoint(remote), false, remote); +// 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 +// on `isLoopbackEndpoint` instead, because they are unreachable as browser +// CultMesh routes: the four IPv6 zone-id spellings, `file://localhost/mesh` +// and `mailto:localhost`. +const CSHARP_LOOPBACK_ANSWERS: readonly (readonly [string, boolean])[] = [ + ["ws://127.0.0.1:4050/mesh", true], + ["ws://127.0.0.2:4050/mesh", true], + ["ws://127.255.255.254:4050/mesh", true], + ["ws://127.1:4050/mesh", true], + ["ws://0x7f000001:4050/mesh", true], + ["ws://2130706433:4050/mesh", true], + ["ws://[::1]:4050/mesh", true], + ["ws://[0:0:0:0:0:0:0:1]:4050/mesh", true], + ["ws://[::ffff:127.0.0.1]:4050/mesh", true], + ["ws://[::ffff:7f00:1]:4050/mesh", true], + ["ws://[::ffff:127.0.0.2]:4050/mesh", false], + ["ws://localhost:4050/mesh", true], + ["ws://LOCALHOST:4050/mesh", true], + ["ws://loopback:4050/mesh", true], + ["ws://LoopBack:4050/mesh", true], + ["ws://localhost.:4050/mesh", false], + ["ws://localhost.localdomain:4050/mesh", false], + ["ws://0.0.0.0:4050/mesh", false], + ["ws://[::]:4050/mesh", false], + ["ws://192.0.2.10:4050/mesh", false], + ["ws://provider.example:4050/mesh", false], + ["ws://127.0.0.1.:4050/mesh", false], + ["ws://127.0.0.1.", false], + ["ws://[::FFFF:127.0.0.1]:4050/mesh", true], + ["ws://[::FFFF:7F00:1]:4050/mesh", true], + ["ws://0177.0.0.1:4050/mesh", true], + ["ws://0177.0.0.1", true], + ["ws://127.0.0.1:0", true], + ["ws://127.0.0.1:0/mesh", true], + ["ws://LOCALHOST", true], + ["ws://localhost:8443", true], + ["ws://localhost:8443/", true], + ["ws://localhost:8443/mesh/path", true], + ["wss://[::1]/mesh", true], + ["wss://[fe80::1]/mesh", false], + ["wss://[::1]:8443", true], + ["ws://[::FFFF:7F00:1]", true], + ["ws://user:pw@127.0.0.1:4050/mesh", true], + ["ws://user@localhost/mesh", true], + ["ws://user:pw@provider.example/mesh", false], + ["ws://127.0.0.1@provider.example/mesh", false], + ["ws://provider.example@127.0.0.1/mesh", true], + ["ws://127.00.0.1/mesh", true], + ["ws://127.0.0.256/mesh", false], + ["ws://127.0.1/mesh", true], + ["ws://127/mesh", false], + ["ws://0x7F.0.0.1/mesh", true], + ["ws://127.0.0.01/mesh", true], + ["ws://LOCALHOST./mesh", false], + ["ws://[::0:1]/mesh", true], + ["ws://[0::1]/mesh", true], + ["ws://[::ffff:0:7f00:1]/mesh", false], + ["ws://[64:ff9b::7f00:1]/mesh", false], + ["ws://127.0.0.1%20/mesh", false], + ["ws://localhost%20/mesh", false], + ["ws://127.0.0.1 /mesh", false], + ["ws://loopback./mesh", false], + ["ws:127.0.0.1/mesh", false], + ["ws:/127.0.0.1/mesh", false], + ["ws:///127.0.0.1/mesh", false], + ["WS://127.0.0.1/mesh", true], + ["ws://127.0.0.1", true], + ["ws://127.0.0.1/", true], + ["ws://127.0.0.1?x=1", true], + ["ws://127.0.0.1#f", true], + ["ws://[::1]", true], + ["ws://[::1]/", true], + ["ws://localhost.localhost/mesh", false], + ["ws://localhost/../mesh", true], + ["ws://127.0.0.1\\mesh", false], + ["ws://127.0.0.1/mesh\n", true], + [" ws://127.0.0.1/mesh", true], + ["ws://127.0.0.1/mesh ", true], + ["ws://127.0.0.1/mesh", false], + ["ws://localhost/mesh", false], + ["ws://localhost\t/mesh", false], + ["ws://[::ffff:127.0.0.1]", true], + ["ws://[::ffff:7f00:0001]/mesh", true], + ["ws://[0:0:0:0:0:ffff:7f00:1]/mesh", true], + ["http://127.0.0.1/mesh", true], + ["cultmesh-state+quic://127.0.0.1:4433/", true], + ["ws://127.0.0.1:99999/mesh", false], +]; + +test("isLoopbackEndpoint answers as System.Uri.IsLoopback did, over every reachable endpoint Soul probed", () => { + assert.equal(CSHARP_LOOPBACK_ANSWERS.length, 82); + const disagreements: string[] = []; + for (const [endpoint, csharp] of CSHARP_LOOPBACK_ANSWERS) { + if (isLoopbackEndpoint(endpoint) !== csharp) disagreements.push(`${JSON.stringify(endpoint)} (C# said ${csharp})`); } + assert.deepEqual(disagreements, []); + assert.equal(isLoopbackEndpoint("not a url"), false); }); // `char.IsWhiteSpace` as .NET 10 answered for each of these code points on @@ -366,10 +449,10 @@ test("the whitespace set is C#'s char.IsWhiteSpace, not String.prototype.trim", } } // The two code points where `trim` would have answered differently. - assert.equal("…".trim().length, 1); - assert.equal(isCSharpWhiteSpace("…"), true); - assert.equal("".trim().length, 0); - assert.equal(isCSharpWhiteSpace(""), false); + assert.equal("\u0085".trim().length, 1); + assert.equal(isCSharpWhiteSpace("\u0085"), true); + assert.equal("\ufeff".trim().length, 0); + assert.equal(isCSharpWhiteSpace("\ufeff"), false); }); test("base64 helpers round-trip across the 32 KiB chunk boundary", () => { @@ -414,12 +497,12 @@ test("padding and duplication the C# constructors clean still verify against the const protocolIds = route.protocolIds!; const accepted: readonly (readonly [string, CultMeshAuthorityRouteView])[] = [ ["a duplicated protocol id", { ...route, protocolIds: [...protocolIds, protocolIds[0]!] }], - ["a padded protocol id", { ...route, protocolIds: [` ${protocolIds[0]!}…`, ...protocolIds.slice(1)] }], + ["a padded protocol id", { ...route, protocolIds: [` ${protocolIds[0]!}\u0085`, ...protocolIds.slice(1)] }], ["a blank protocol id", { ...route, protocolIds: [...protocolIds, " "] }], ["the protocol ids reversed", { ...route, protocolIds: [...protocolIds].reverse() }], - ["a padded generation", { ...route, generation: ` ${route.generation} ` }], + ["a padded generation", { ...route, generation: ` ${route.generation}\u3000` }], ["a padded endpoint", { ...route, endpoint: ` ${route.endpoint} ` }], - ["a padded Odin key id", { ...route, certificate: { ...certificate, odinKeyId: ` ${certificate.odinKeyId} ` } }], + ["a padded Odin key id", { ...route, certificate: { ...certificate, odinKeyId: `\u00a0${certificate.odinKeyId} ` } }], ["a padded provider key id", { ...route, certificate: { ...certificate, providerKey: { ...certificate.providerKey, keyId: ` ${certificate.providerKey.keyId} ` } }, diff --git a/scripts/mutate-cultmesh-authority.mjs b/scripts/mutate-cultmesh-authority.mjs index 1e86133a..25c289ab 100644 --- a/scripts/mutate-cultmesh-authority.mjs +++ b/scripts/mutate-cultmesh-authority.mjs @@ -188,6 +188,26 @@ const mutations = [ old: 'host === "::ffff:7f00:1" ||', new: "", }, + { + rule: "a trailing dot on the host is refused, as System.Uri refuses it", + old: ' || host.endsWith(".");', + new: ";", + }, + { + rule: "a scheme not followed by // and an authority is refused", + old: " if (!shape) return true;", + new: " if (!shape) return false;", + }, + { + rule: "a backslash in the endpoint is refused", + old: ' if (raw.includes("\\\\")) return true;\n', + new: "", + }, + { + rule: "a non-ASCII host is refused, so URL cannot fold a fullwidth form to ASCII", + old: " return /[^\\u0021-\\u007e]/.test(host)", + new: " return false", + }, { rule: "protected scheme: any scheme containing quic", old: 'scheme.includes("quic")',