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

A bot took six API keys in six hours. Gmail helped

Six accounts signed up overnight. These were the addresses: [email protected] [email protected] [email protected] [email protected] wi.ll.iam...su.ll.iv.a.n@gmail.

Six accounts signed up overnight. These were the addresses:

If you have run a signup form before, you already know. Gmail ignores dots in the local part. [email protected] and [email protected] are the same inbox. It is documented behaviour, not a bug — it exists because Gmail launched in a world where people typed addresses off business cards.

My uniqueness check was WHERE lower(email) = .... Six strings, six rows, six API keys handed out.

Whose inboxes were they

My first thought was that someone had taken one address and mutated it six ways to farm keys off me. Then I stripped the dots:

Six different people. Plausible full names. This is a harvested address list with dot-mutation applied, and the mutation is not aimed at my duplicate check at all — it is a generic evasion habit, applied to a list, by something that signs up for things.

Which means the verification emails went to six real strangers who have never heard of us. That is the part I care about more than the keys. A handful of "report spam" clicks on a young sending domain and your mail stops reaching the people who did ask for it — silently, and exactly when you start getting real users.

Hole one: the cheap door was the unguarded one

Some context, because it is the part that stung. The day before, we had opened part of our API — a package-security lookup — to callers with no key at all. Twenty requests an hour per address, five packages per request. We spent real time on those numbers: enough to try the thing, not enough to run on it. What a caller sees when they run out. How they learn the limit from the response rather than from docs they have not read.

An API key skips all of it.

And we have two ways to get one.

POST /v1/auth/request-key takes an email, mails the key, and has had a per-address daily budget since the day it was written — five. The comment above it literally says it bounds key-farming.

POST /v1/auth/signup takes an email and a password and had nothing.

And signup is the cheaper door, because email verification is off by default in our deployment. Sign up, get a working key in the response. No inbox needed.

So the endpoint with "this bounds key-farming" in its comment was guarded, and the one next to it — doing the same thing, faster — was not. The budget function already existed. It was four lines to call it.

allowed, _used = _request_key_throttle(client_ip)
if not allowed:
    raise HTTPException(429, { ... })

I don't think this is unusual. You write the dangerous-looking endpoint carefully because it looks dangerous, and the ordinary one stays ordinary.

Hole two: and here is where the obvious fix is wrong

The tempting fix for the Gmail thing is to normalise every address: strip dots, strip anything after +, compare that.

Don't. Dots are only insignificant at Gmail.

[email protected] and [email protected] are two different mailboxes, very possibly two colleagues. Fold dots globally and you will eventually tell a real person their account already exists, and you will not find out, because people who hit that just leave.

Plus-tags are safer but still not universal — + is a legal character in a local part, and only some providers route on it. So the rule has to be per-provider:

_PLUS_TAG_DOMAINS = frozenset({
    "gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
    "yahoo.com", "icloud.com", "me.com", "fastmail.com",
    "proton.me", "protonmail.com",
})
_DOTLESS_DOMAINS = frozenset({"gmail.com", "googlemail.com"})

def canonical_email(addr: str) -> str:
    local, _, domain = addr.strip().lower().rpartition("@")
    if domain == "googlemail.com":
        domain = "gmail.com"
    if domain in _PLUS_TAG_DOMAINS:
        local = local.split("+", 1)[0]
    if domain in _DOTLESS_DOMAINS:
        local = local.replace(".", "")
    return f"{local}@{domain}"

Two details that matter more than the function.

Store it, don't compute it at query time. A new column, a unique index on it, and the original address kept exactly as typed — because mail goes to what the person wrote, and canonical_email is only ever allowed to answer "is this the same mailbox".

Make the index UNIQUE and let the migration fail. If it won't build on your live database, two of your existing accounts share an inbox, and you want to see that rather than have a WHERE NOT EXISTS quietly paper over it.

The thing I actually took away

Rate limits get designed at the surface you are thinking about.

We had just spent a day on the keyless numbers, and that was good work. But the form that hands out the credential which ignores those numbers was one door over, unmetered, because it was a signup form and signup forms are boring.

If you have a free tier with a carefully chosen number in it, go and look at every path that issues the credential which ignores that number. In our case there were two, and only one of them had ever been thought about.

Postscript, for the six people whose addresses were used: sorry. The verification mail was not from anyone you know, and nothing was done with it. The accounts are gone.

📰 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.