From d1fd52493d1e624d53845ccabc33ed5292971053 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 12:38:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(moshpit):=20letters=20and=20digits=20only?= =?UTF-8?q?=20=E2=80=94=20no=20dashes=20in=20a=20name=20or=20an=20ending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dash is the cheapest way to mint a near-miss of an ending someone else holds. `.crypto` and `.cryp-to` read as the same thing at a glance and sort next to each other, so allowing dashes turns every claimed ending into a family of look-alikes worth squatting. The namespace is one level deep and first come first served: there is no second level to retreat to and no dispute process to appeal into, which makes the near-miss the whole attack rather than a nuisance. Blast radius measured before changing anything: of 4593 endings claimed, exactly one contains a dash — `.lazy-loaded`, minted today while testing whether dashes were allowed. Nothing else in the namespace moves. The rule is changed in both places at once on purpose. A name the bridge accepts and the registry rejects resolves to a page saying it does not exist, which is a worse failure than either rule alone. The cost is real names nobody can have. That is the trade, and it is cheaper than policing look-alikes forever. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/moshpit-name.mjs | 13 ++++++++++++- apps/pwa/test/moshpit-name.test.mjs | 21 ++++++++++++++++++++- src/dns.mjs | 8 +++++++- test/dns.test.mjs | 12 ++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index 1a287b2..f7be06c 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -26,7 +26,18 @@ export const RESERVED_TLDS = new Set([ ]); /** A TLD label: lowercase letters, digits and dashes; no leading/trailing dash. */ -const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +// Letters and digits only — no dashes, though DNS would allow them. +// +// A dash is the cheapest way to mint a near-miss of a name someone else holds. +// `.crypto` and `.cryp-to` read as the same ending at a glance and sort next to +// each other, so allowing dashes turns every claimed ending into a family of +// look-alikes worth squatting. The namespace is one level deep and first come +// first served, which makes that the whole attack: there is no second level to +// retreat to and no dispute process to appeal it. +// +// The cost is real names nobody can have — `lazy-loaded` cannot be an ending. +// That is the trade, and it is cheaper than policing look-alikes forever. +const LABEL = /^[a-z0-9]{1,63}$/; /** A hostname label. Unlike a TLD, an all-numeric label is valid. */ export function normalizeLabel(input) { diff --git a/apps/pwa/test/moshpit-name.test.mjs b/apps/pwa/test/moshpit-name.test.mjs index 6cf9648..7357bf6 100644 --- a/apps/pwa/test/moshpit-name.test.mjs +++ b/apps/pwa/test/moshpit-name.test.mjs @@ -13,7 +13,7 @@ test("normalizeTld accepts what people actually type", () => { assert.equal(normalizeTld("eggs"), "eggs"); assert.equal(normalizeTld(".eggs"), "eggs"); assert.equal(normalizeTld(" .EGGS "), "eggs"); - assert.equal(normalizeTld("web3-agents"), "web3-agents"); + assert.equal(normalizeTld("web3agents"), "web3agents"); }); test("normalizeTld rejects what could never be a TLD", () => { @@ -125,3 +125,22 @@ test("all-numeric endings", async (t) => { assert.equal(parseMoshpitName("0.0"), null); }); }); + +test("dashes are not part of a Moshpit name", () => { + // A dash is the cheapest way to mint a near-miss of an ending someone else + // holds — `.cryp-to` beside `.crypto` — and a namespace one level deep with + // no dispute process has nowhere to put the argument. + assert.equal(normalizeTld("lazy-loaded"), null); + assert.equal(normalizeTld("cryp-to"), null); + assert.equal(normalizeLabel("register-me"), null); + assert.equal(normalizeLabel("-leading"), null); + assert.equal(normalizeLabel("trailing-"), null); + + // Unchanged either side of the dot. + assert.equal(normalizeTld("oranges"), "oranges"); + assert.equal(normalizeTld("420"), "420"); + assert.equal(normalizeLabel("california"), "california"); + assert.equal(normalizeLabel("123"), "123"); + assert.equal(normalizeLabel("a".repeat(63)), "a".repeat(63)); + assert.equal(normalizeLabel("a".repeat(64)), null); +}); diff --git a/src/dns.mjs b/src/dns.mjs index b0c7f8f..5953a2a 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -205,7 +205,13 @@ export function parseRegistryName(hostname) { if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null; const parts = host.split("."); if (parts.length !== 2) return null; - const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + // Letters and digits only, matching the registry. A dash is the cheapest way + // to mint a look-alike of an ending someone else holds, and in a namespace + // one level deep and first come first served there is nowhere to retreat to. + // Keeping the rule here identical to the registry's matters more than the + // rule itself: a name this bridge accepts and the registry rejects resolves + // to a page that says it does not exist. + const LABEL = /^[a-z0-9]{1,63}$/; const [label, tld] = parts; if (!LABEL.test(label) || !LABEL.test(tld)) return null; return { label, tld }; diff --git a/test/dns.test.mjs b/test/dns.test.mjs index 9cd39e1..d10ed06 100644 --- a/test/dns.test.mjs +++ b/test/dns.test.mjs @@ -251,3 +251,15 @@ test("answerFor hands the bridge a bare address, not the stored target", async ( }); assert.equal(address, "2606:4700:4700::1111"); }); + +test("dashes are not part of a Moshpit name", () => { + // Cheap look-alikes of an ending someone already holds. The namespace is one + // level deep and first come first served, so `.cryp-to` next to `.crypto` has + // nowhere to be appealed to. + assert.equal(parseRegistryName("blue.lazy-loaded"), null); + assert.equal(parseRegistryName("register-me.eggs"), null); + assert.equal(parseRegistryName("a-b.c-d"), null); + // Still fine either side of the dot. + assert.deepEqual(parseRegistryName("california.oranges"), { label: "california", tld: "oranges" }); + assert.deepEqual(parseRegistryName("123.420"), { label: "123", tld: "420" }); +});