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" }); +});