Refuse the endpoint spellings WHATWG URL repairs and System.Uri does not

`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.

`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.

The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.

The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.

Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 17:22:06 +02:00
commit 803ee12a7a
3 changed files with 172 additions and 22 deletions

View file

@ -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" ||

View file

@ -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} ` } },

View file

@ -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")',