Back to Activepieces

A sign-up address is checked against ZeroBounce, not a bundled blocklist

brain/knowledge/decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md

0.89.09.5 KB
Original Source

A sign-up address is checked against ZeroBounce, not a bundled blocklist

Decision

zerobounce.maySignUp — the module's only export — asks ZeroBounce's GET /v2/validate about every address that has no account yet, and refuses the sign-up on spamtrap, abuse, or do_not_mail with a sub-status of disposable, toxic, possible_trap or global_suppression. The disposable-email-domains package leaves the sign-up path. The check runs only when AP_ZEROBOUNCE_API_KEY is set, and AP_ALLOW_DISPOSABLE_EMAILS is deleted — the key's presence is the switch.

Context

Cloud was taking botted sign-ups. The defence was a Set built from disposable-email-domains at module load, consulted by disposableEmail.assertMaySignUp. That list is a build artefact: it is fixed at release time, so a domain registered after the build is invisible until the next release, and an operator running an older tag never sees it at all. The purge script that cleans up the platforms those sign-ups leave behind needs an explicit --domains list precisely because "the package does not know about" the evasion domains actually in use.

Why

A blocklist that ships with the release is always behind the attacker. Throwaway domains are registered continuously and cost nothing; a list compiled weeks earlier and frozen into a tag cannot see them. A hosted service answers about the domain as it is today, which is the only version that matters.

Junk, not non-existence. status: invalid — a mailbox that does not exist — is deliberately allowed through, even though random-address bots at real domains are exactly what it would catch. Refusing it turns the public sign-up endpoint into a mailbox-existence oracle for any address at any domain, and it refuses the ordinary member who typos their own address. Bots that get in this way still cannot verify a code, so they get an unverified identity row and nothing else, which the purge path already handles.

Role addresses are accepted. do_not_mail also covers role_based, role_based_catch_all and mx_forward. Refusing those would refuse info@, sales@ and automation@ — ordinary ways a team signs up for an automation tool. The reject set is deliberately the abuse half of do_not_mail, not the whole status.

Fail open, always. An unreachable endpoint, a timeout, an invalid key, an exhausted credit balance — every one of these lets the address through with a log line. A validation outage that blocked sign-up would be a worse outage than the abuse it prevents, and ZeroBounce answers HTTP 200 with an {"error": ...} body for the key and credit cases, so that body is checked explicitly rather than left to the status code. This mirrors the posture turnstile.ts takes in the same directory, with one difference: Turnstile refuses when siteverify answers with an error status, and there is no equivalent here, because any ZeroBounce answer we cannot read is simply no verdict.

The key is the feature flag. .claude/rules/self-hosting.md forbids anything that looks enabled but is silently broken without setup, and a paid API cannot be a default. Gating on the key means an instance with no ZeroBounce account sees no check and no error, and one with a key gets it with no second switch to discover. AP_ALLOW_DISPOSABLE_EMAILS therefore had nothing left to control and was removed rather than kept as a knob that only ever agrees with the key.

The captcha comes first. In requestCode, turnstile.assertSolved runs before the validation call, so no credit is spent on a request that has not solved a challenge. That ordering is load-bearing, not incidental: it is what makes per-address credit spend bounded by solved captchas rather than by packets.

Consequences

The sign-up path now depends on a third party, and self-hosters lose a check they had by default.

  • An instance with no key accepts throwaway addresses where it previously refused them. This is a deliberate loosening for self-hosters, and deliberately not recorded in breaking-changes.mdx: nothing errors, nothing has to be done on upgrade, and the call was that no self-hoster was leaning on the bundled list. The cost is that the loss is silent — anyone who was relying on it learns from throwaway sign-ups rather than the upgrade guide. AP_ALLOW_DISPOSABLE_EMAILS is removed on the same reasoning: an instance that still sets it boots fine and the value is ignored. Self-hosted instances are not the ones under sign-up attack, and the alternative — shipping a stale list to everyone — is what was being replaced.
  • Burning the credit balance is the bypass, so disposable domains are cached. Fail-open plus an exhaustible balance means an attacker who spends the credits makes every subsequent address pass. A refusal creates no identity, so the same address can be re-tried indefinitely; without a cache each attempt cost a credit. disposable verdicts therefore accumulate in one distributedStore key, zerobounce:disposable-domains:v1, holding an ordered array of at most 500 domains: a hit is an array membership test, and a new domain is appended with the oldest dropped once the list is full. Hammering one domain costs one credit, ever, rather than one per attempt. This bounds repeat abuse, not breadth: rotating across new domains still costs a credit each, which is the edge's job (a Cloudflare rate-limiting rule on the sign-up paths), not the app's.
  • The cache is a bounded list with no TTL, not one key per domain. One key means one read on the hot path and a size cap that is simply slice(-500), where per-domain keys would need a separate index to bound them. It is insertion-ordered, not true LRU — a hit does not promote its entry, because that would put a Redis write on every refused sign-up. The read-modify-write is not atomic, so two instances adding different domains at once can lose one; the cost is one credit later, which is the right trade for a cache. Dropping the TTL makes an entry live until 500 newer domains evict it, so correcting a wrong verdict means deleting that single key and letting the list rebuild — cheap, but someone has to know the key exists. Redis is a cache, not a record: a flush or an allkeys-lru eviction silently drops the list, which costs credits and nothing else.
  • Only a domain-shaped verdict may be cached by domain. disposable is a property of the domain; toxic, global_suppression, spamtrap and abuse describe one mailbox, so caching those by domain would refuse every account on gmail.com after a single bad one. Allow-verdicts are not cached either, since a cached "this domain is fine" would skip the address-level checks for every other mailbox on it. Four tests pin each half of this.
  • One credit per address that has no account yet. requestCode skips the check entirely for an existing identity, so a repeat sign-in costs nothing. A refused address creates no identity, so a bot re-hitting the same address does spend a credit per attempt; the rate limits and the captcha in front are what bound that. Running out of credits degrades to fail-open, not to an outage.
  • Both paths refuse silently, each by mimicking its own legitimate outcome. The lib only answers the question — maySignUp returns a boolean and throws nothing — because the two call sites need different disguises. requestCode returns the same 204 as a success, creating no identity and sending no code, so the card advances to "Enter your code" and none arrives. signUp throws EMAIL_IS_NOT_VERIFIED with the address, which is exactly what a real Cloud sign-up returns (the identity is created unverified, then getOnboardingResponse throws it), so the form renders its ordinary CheckEmailNote. Both mirror the silent-return 000027 already uses for the invitation check, for the same reason: a distinguishable answer turns a public endpoint into an oracle, here for "is this address disposable". DOMAIN_NOT_ALLOWED is therefore absent from this path entirely and means only the platform domain allow-list again. The cost is that a false positive is invisible to the member and to support: the only trace is the address refused log line, so that log is the sole way to answer "why did no code arrive".
  • A mimicked response has to match the real one down to value normalization. The first cut echoed params.email as submitted while the genuine path echoes it lowercased, having read it back from the stored identity — so a mixed-case address came back [email protected] on a refusal and [email protected] on a real sign-up, which is a perfectly good oracle. Any future mimicry needs the same toLowerCase().trim() the identity service applies. The test that pins this asserts toEqual against the whole body, not just the code, which is what caught it.
  • The reject set is domain-flavoured, so the error stayed DOMAIN_NOT_ALLOWED. No new error code and no web change. If invalid is ever added to the reject set, that message ("Email domain is disallowed") becomes wrong for the address-level cases and needs its own code.
  • The offline blocklist survives only inside the purge script, which classifies existing identities in bulk and must run without spending a credit per row. disposable-email-domains stays a runtime dependency for it — the runtime image installs with bun install --production, so a devDependency would not exist where that script runs.
  • A member who already holds an accepted invitation is never refused, unchanged from the blocklist behaviour.