Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

121,570 throwaway domains decide our sign-up, and not one byte of the list reaches the browser

Nakodo's free plan needs no card. That is deliberate, and it is also an invitation: the cheapest way to get ten free plans is ten inboxes you do not own, and there are websites that will hand you one in a second without

Nakodo's free plan needs no card. That is deliberate, and it is also an invitation: the cheapest way to get ten free plans is ten inboxes you do not own, and there are websites that will hand you one in a second without asking for anything at all.

So new accounts have to come from an address someone keeps. The whole check is this, in src/lib/email/disposable.ts:

import exact from "disposable-email-domains";
import wildcard from "disposable-email-domains/wildcard.json";

const exactDomains = new Set(exact as unknown as string[]);
const wildcardDomains = new Set(wildcard as string[]);

export function isDisposableEmail(value: string): boolean {
  const at = value.lastIndexOf("@");
  if (at === -1) return false;
  const domain = value.slice(at + 1).trim().toLowerCase();
  if (!domain) return false;
  if (exactDomains.has(domain)) return true;
  const labels = domain.split(".");
  for (let i = 0; i < labels.length - 1; i++) {
    if (wildcardDomains.has(labels.slice(i).join("."))) return true;
  }
  return false;
}

Twenty lines around two Sets. The lists come from the disposable-email-domains package, and the numbers are worth knowing before you decide where this code is allowed to run. Measured from the installed copy:

exact 121570   wildcard 399
index.json on disk: 2,460,096 bytes

Two and a half megabytes of JSON, and 388 of the 399 wildcard entries also appear in the exact list, so the wildcard file is very nearly a subset whose job is the eleven entries and the subdomain rule rather than the names themselves.

Two lists, two rules

The exact list is a straight membership test. The wildcard list matches a domain and everything under it, so inbox.mailinator.com and a.b.mailinator.com both count, which is the whole point of the second file: a provider that hands out subdomains would otherwise need an entry per subdomain, for ever.

The loop walks the domain upwards, one label at a time, and stops one short:

for (let i = 0; i < labels.length - 1; i++)

labels.length - 1 is the guard. Without it, the last iteration tests the bare public suffix, which is nobody's provider and is exactly the kind of entry that would ban a quarter of the internet if it ever appeared in the list by mistake. The deepest entry in the exact list is ten labels long, so the loop does have real work to do on some inputs.

Two of the five tests in disposable.test.ts exist for the shape of that walk rather than for any particular provider:

test("a wildcard provider only matches whole labels", () => {
  assert.equal(isDisposableEmail("[email protected]"), false);
});

A naive endsWith says true there. zzqmailinator.com is a different domain that happens to end in the same letters, and if someone registers one, the list should not decide for them.

test("what isn't an address is not this check's problem", () => {
  for (const other of ["", "sam", "sam@", "@mailinator.com"]) {
    assert.equal(isDisposableEmail(other), other === "@mailinator.com", other);
  }
});

That last assertion looks like a bug and is a boundary. @mailinator.com is not an address, and this function says true about it, because its only question is what comes after the final @. Something else owns "that is not an address", and reports it in a sentence that helps. Two checks fighting over the same input is how you end up telling someone their perfectly good address is a temporary one.

lastIndexOf rather than indexOf, for the same reason: the local part of an address is allowed to contain an @ in quotes, and the domain is whatever follows the last one.

Which form it runs on

This is the part I would get wrong if I wrote it quickly. The schema is split in two:

const credentials = z.object({
  email: z.email("Enter a valid email address"),
  password: z.string().min(8, "Use at least 8 characters for the password"),
});

const newCredentials = credentials.extend({
  email: credentials.shape.email.refine(
    (email) => !isDisposableEmail(email),
    "That's a temporary email address. Use a permanent one, so you don't lose the account or miss a reply.",
  ),
});

signIn parses with credentials. signUp parses with newCredentials. Only new accounts are turned away.

The reason is that the lists grow. A domain that was somebody's mail host last year can be on the list this year, and an account created before that happened is a real account with real campaigns in it. If the sign-in form shared the stricter schema, that person would be locked out of their own data by a dependency update, and so would their password reset, and so would their email confirmation. A check that decides who may join must not be allowed to decide who may come back.

The error sentence says why rather than no. "Use a permanent one, so you don't lose the account or miss a reply" is true of this product specifically: an address that evaporates takes the campaign and every reply waiting on it along with it.

None of it ships

disposable-email-domains is imported by one file, which is imported by one server action file, which starts with "use server". Two and a half megabytes of domains, for a check whose whole job is to run once per sign-up, must never find its way into a client component's import graph.

That is a claim you can test from outside. The sign-up form is on the login page; fetch it, pull out every script it loads, and grep:

$ curl -s https://nakodo.app/login | grep -o '/_next/static/immutable/chunks/[A-Za-z0-9_.-]*\.js' | sort -u | wc -l
13

Thirteen chunks, 717,867 bytes of JavaScript between them, and zero occurrences of mailinator in any of them. Nor does the page's HTML contain the refusal sentence: the words "temporary email address" appear nowhere until the server decides to say them. Both of those are the kind of thing that stays true only if someone checks, because all it takes is one shared helper file imported from a form component to drag the whole list across the boundary, and nothing fails when it does. The build gets bigger and the sign-up page gets slower, on the exact page where a slow first load costs you the account.

The matching product decision is on the pricing page, which says the free plan costs nothing and needs no card, and explains what happens when its monthly allowance runs out. Those two sentences are why this file exists.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.