Sitelet https://github.com/NodeOps-app/treg2/commit/19ef381c6a717ba8df50743199f7ad222fc6521c
Skip to content

Commit 19ef381

Browse files
authored
refactor(auth): the email-domain blocklist is configuration only (superdesigndev#373)
Removes BLOCKED_EMAIL_DOMAINS and BLOCKED_EMAIL_KEYWORDS from the code. TREG_BLOCKED_EMAIL_DOMAINS is now the whole list, and an unset value blocks nothing. The substring rules were the reason to look. Measured against a public throwaway-domain corpus they matched 0.17% of it, added nothing over the exact domain entries, and refused a real company whose domain merely contained one of the strings - with no way to unblock it short of a deploy, which is the opposite of what a blocklist needs to be. The shipped domain entries move to configuration for the same reason: a new domain costs the other side minutes, so the list is only worth anything if it can be edited in the same minutes.
1 parent 2ad5b9f commit 19ef381

8 files changed

Lines changed: 100 additions & 116 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ Environment variables (prefix `TREG_`, read from `.env`):
298298
| `TREG_META_CLIENT_ID` / `_SECRET` | *(empty)* | Meta app credentials for Facebook Pages, Meta Ads, and optional Instagram `page-tools` |
299299
| `TREG_OAUTH_REVIEW_PENDING` | `instagram-login,page-messages` | Registry review keys awaiting production access. Remove `page-messages` after Page messaging approval; set empty after direct Instagram approval. |
300300
| `TREG_RESEND_API_KEY` / `TREG_EMAIL_FROM` | *(empty)* | transactional email via Resend (OTP codes + invites); From must be a Resend-verified sender |
301-
| `TREG_BLOCKED_EMAIL_DOMAINS` | *(empty)* | comma-separated email domains added to the built-in throwaway/farm blocklist, refused at every sign-up/sign-in door and at team creation (subdomains included, case-insensitive) |
301+
| `TREG_BLOCKED_EMAIL_DOMAINS` | *(empty)* | comma-separated email domains refused at every sign-up/sign-in door and at team creation (subdomains included, case-insensitive). Empty blocks nothing — no list ships in the code |
302302
| `TREG_ADMIN_TOKEN` | *(empty)* | cross-tenant **super-admin** bearer; authorizes every `/admin/*` endpoint. Empty disables the env path (only `is_superadmin` users reach `/admin`). Keep it long + secret. |
303303
| `TREG_EMAIL_DEV_MODE` | `false` | when true, `/auth/email/start` returns the OTP in its response (no mail sender needed) — **dev/local only**, never in prod. |
304304

‎SECURITY.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ environment) and enforcement happens server-side and in the operating system.
3030
- **Server runs are resource-limited.** `treg run --server` executes each CLI with a scrubbed environment
3131
(treg's own secrets removed), a per-run throwaway home, an allow-list of runnable commands, output
3232
redaction, and POSIX resource limits (CPU, file size, no core dumps).
33-
- **Signup abuse has a brake.** Every new team gets a small promotional balance, which makes
34-
throwaway-email farming worth an attacker's time. An email-domain blocklist (throwaway-mail rules
35-
and confirmed farm roots in code, plus `TREG_BLOCKED_EMAIL_DOMAINS` for a new root without a
36-
redeploy; subdomains included, case-insensitive, domain only) refuses the address at every sign-up
37-
and sign-in door and at both team-creating endpoints. The refusal names neither the list nor the
38-
domain, every block is logged, and a classifier failure lets the sign-in through rather than
39-
breaking real signups.
33+
- **Signup abuse has a brake.** Every new team gets a small promotional balance, which makes bulk
34+
registration on throwaway addresses worth an attacker's time. `TREG_BLOCKED_EMAIL_DOMAINS` (the
35+
whole blocklist — no list ships in the code, so an unset value blocks nothing; subdomains
36+
included, case-insensitive, domain only) refuses the address at every sign-up and sign-in door and
37+
at both team-creating endpoints. The refusal names neither the list nor the domain, every block is
38+
logged, and a classifier failure lets the sign-in through rather than breaking real signups. It is
39+
a speed bump, not a fix: a new domain costs the other side minutes, so treat the variable as
40+
something to edit during an incident, and suspend the accounts already created separately.
4041

4142
## Known limitations (by design, documented on purpose)
4243

‎docs/context/architecture/multi-tenancy.md‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,20 +116,22 @@ pair, so every list/create/mutation and the proxy are scoped to the caller's org
116116
`list_members` carries `is_agent` so one roster can show people and machines apart.
117117
- **Email-domain blocklist.** The same choke points, for throwaway mail and domains used for bulk
118118
registration. A new team is created with a promotional balance, which is what makes registering in
119-
bulk on throwaway addresses worth someone's while. **Two tiers, one classifier**
120-
(`_is_blocked_email` in `domain/identity/access.py`, pure: it only answers). The CODE tier is
121-
`BLOCKED_EMAIL_DOMAINS` (domains confirmed abusive in our own data, and `my.id` so every free
122-
`.my.id` subdomain falls to the walk) plus `BLOCKED_EMAIL_KEYWORDS`, substring rules on the domain
123-
(`tempmail`, `mailinator`, `guerrilla`, `10minute`, ...) that catch domains no static list has
124-
seen. The OPS tier is `TREG_BLOCKED_EMAIL_DOMAINS`, comma-separated, **added to** the code tier
125-
and parsed once per distinct value in `config.py` (trim, drop a leading `@`/`.`, lowercase, and
126-
drop any dotless entry so a typed `com` cannot refuse the world): the next domain is a **dashboard
127-
edit, no redeploy**. The rules, each of which exists because the obvious implementation is wrong:
128-
match the **domain only**, never the whole address (matching the address false-flags real users
129-
whose username happens to contain a keyword); **walk parent domains**, whole labels off the front
130-
and never the bare last label, because registering `<random>.<blocked-root>` is otherwise a
131-
one-line bypass; **sign-in as well as sign-up** (an account that predates the listing gets no
132-
new session; existing accounts are suspended out of band). The DECISION lives in the application
119+
bulk on throwaway addresses worth someone's while. **Entirely configuration**: the classifier
120+
(`_is_blocked_email` in `domain/identity/access.py`, pure — it only answers) reads
121+
`TREG_BLOCKED_EMAIL_DOMAINS` and nothing else, parsed once per distinct value in `config.py`
122+
(trim, drop a leading `@`/`.`, lowercase, and drop any dotless entry so a typed `com` cannot
123+
refuse the world). Unset — the default — blocks nothing, and the next domain is a **dashboard
124+
edit, no redeploy**. No list lives in the code: a blocklist is a speed bump, since a new domain
125+
costs the other side minutes, so its only real value is being editable in the same minutes, which
126+
a deploy is not. Substring rules on the domain were tried and removed — measured against a public
127+
throwaway-domain corpus they matched 0.17% of it, added nothing over the exact entries, and
128+
refused a real company whose domain merely contained one of the strings. The rules that remain,
129+
each because the obvious implementation is wrong: match the **domain only**, never the whole
130+
address (matching the address false-flags real people whose username happens to contain a listed
131+
string); **walk parent domains**, whole labels off the front and never the bare last label,
132+
because registering `<random>.<listed-domain>` is otherwise a one-line bypass; **sign-in as well
133+
as sign-up** (an account that predates the listing gets no new session; existing accounts are
134+
suspended out of band). The DECISION lives in the application
133135
layer, `signup.blocked_email(email, door)`: it refuses, writes one structured line per block
134136
(`event=signup_blocked_domain door=<door> domain=<domain>` — the refusal reveals nothing, so the
135137
log is the only detection a burst has), and **fails open**, logging `event=blocklist_error`

‎docs/context/interface/api.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,8 @@ validated before resolving the shared HTTP client. `/auth/logout` remains an HTT
363363

364364
- **Identity doors:** GitHub, Google and email OTP share first-proof user provisioning. They create
365365
a user without an automatic org; new users name their first team through onboarding or the CLI
366-
login picker. Suspended users are refused at every door, and so is any address on a blocked email
367-
domain (throwaway-mail rules and confirmed farm roots in code, plus `TREG_BLOCKED_EMAIL_DOMAINS`;
366+
login picker. Suspended users are refused at every door, and so is any address on a domain listed
367+
in `TREG_BLOCKED_EMAIL_DOMAINS` (the whole blocklist — nothing is blocked when it is unset;
368368
subdomains included): the OTP start and verify, both social callbacks, the emailed invite link,
369369
plus `POST /users`, `POST /orgs` and `POST /invites/accept`, all with the same 403 `this address
370370
cannot be used to sign in` the machine-identity guard uses. See

‎docs/context/ops/deploy.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -308,17 +308,17 @@ bound to a closed maintenance loop. Calling `maintenance.upgrade()` directly doe
308308
code is exposed only through `Settings.expose_dev_code`, which requires `email_dev_mode` **and** a
309309
**local sqlite** `database_url` — so even a stray `TREG_EMAIL_DEV_MODE=true` on Postgres (a real deploy)
310310
can never leak a login code.
311-
- `blocked_email_domains` (`TREG_BLOCKED_EMAIL_DOMAINS`, default empty) - the OPS tier of the
312-
email-domain blocklist: comma-separated domains ADDED to the code tier (treg's confirmed farm
313-
roots and the throwaway-mail keyword rules in `domain/identity/access.py`), refused at every
314-
identity door and at both team-creating doors (`POST /users` and `POST /orgs`). Example:
315-
`newfarm.io,other-farm.net`. Case-insensitive; a listed domain also blocks its subdomains; a
316-
leading `@` or `.` and surrounding whitespace are tolerated; a dotless entry (`com`) is ignored.
317-
The signup-grant-farm brake: edit it in the Render dashboard the moment a new root appears, no
318-
redeploy; promote a root into the code tier in the next PR. Empty adds nothing (the code tier
319-
stays in force). Existing accounts on a listed domain must be suspended separately (`/admin`);
320-
the list only stops new sessions and new teams. Each block writes one
321-
`event=signup_blocked_domain door=... domain=...` log line, so a wave is countable. See
311+
- `blocked_email_domains` (`TREG_BLOCKED_EMAIL_DOMAINS`, default empty) - the WHOLE email-domain
312+
blocklist: comma-separated domains refused at every identity door and at both team-creating doors
313+
(`POST /users` and `POST /orgs`). There is no list in the code, so **this variable is the only
314+
thing standing between a bulk-registration run and the promo grant** — an empty value blocks
315+
nothing. Example: `example-one.io,example-two.net`. Case-insensitive; a listed domain also blocks
316+
its subdomains; a leading `@` or `.` and surrounding whitespace are tolerated; a dotless entry
317+
(`com`) is ignored so one typo cannot refuse every address on earth. Edit it in the Render
318+
dashboard the moment a new domain appears; changing it restarts the service. Existing accounts on
319+
a listed domain must be suspended separately (`/admin`); the list only stops new sessions and new
320+
teams, not tokens already issued. Each block writes one
321+
`event=signup_blocked_domain door=... domain=...` log line, so a burst is countable. See
322322
[multi-tenancy](../architecture/multi-tenancy.md).
323323
- `run_proof` (`TREG_RUN_PROOF`) — the **isolated-runner proof** for `treg run --local`. A local run whose
324324
grant would return a secret the caller does **not** own (a shared-key tool a member may run but not read)

‎src/treg/config.py‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -437,20 +437,19 @@ def _async_pg_driver(cls, v: str) -> str:
437437
# must be explicitly enabled (TREG_EMAIL_DEV_MODE=true) for local testing without a mail sender.
438438
email_dev_mode: bool = False
439439

440-
# The OPS tier of the email-domain blocklist (TREG_BLOCKED_EMAIL_DOMAINS), comma-separated:
441-
# "newfarm.io,other-farm.net". ADDED to the code tier in `domain/identity/access.py` (treg's
442-
# confirmed farm roots and the throwaway-mail keyword rules), never replacing it. A listed domain
443-
# blocks itself AND every subdomain, case-insensitively, at every sign-up and sign-in door and at
444-
# the two doors that mint a promo-funded team (POST /users, POST /orgs). It exists because a
445-
# signup-grant farm moves to a new root in minutes and the answer has to be a dashboard edit, not
446-
# a deploy. Empty (the default) adds nothing. Existing accounts on a listed domain are suspended
447-
# out of band, so listing a domain strands nobody legitimate. A blocklist, deliberately: no
448-
# allowlist, no table, no admin UI.
440+
# The WHOLE email-domain blocklist (TREG_BLOCKED_EMAIL_DOMAINS), comma-separated:
441+
# "example-one.io,example-two.net". There is no list in the code; empty (the default) blocks
442+
# nothing. A listed domain blocks itself AND every subdomain, case-insensitively, at every
443+
# sign-up and sign-in door and at the two doors that create a promo-funded team (POST /users,
444+
# POST /orgs). Configuration rather than code because bulk registration moves to a new domain in
445+
# minutes, and a defence that needs a deploy to keep up is always behind. Existing accounts on a
446+
# listed domain are suspended out of band, so listing one strands nobody legitimate. A blocklist,
447+
# deliberately: no allowlist, no table, no admin UI.
449448
blocked_email_domains: str = ""
450449

451450
@property
452451
def blocked_email_domain_set(self) -> frozenset[str]:
453-
"""The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = the code tier alone."""
452+
"""The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = nothing is blocked."""
454453
return _blocked_email_domains(self.blocked_email_domains)
455454

456455
# Frictionless local mode: `curl … | sh` brings up a server you are already signed into, with no

‎src/treg/domain/identity/access.py‎

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -243,38 +243,23 @@ def _is_machine_email(email: str) -> bool:
243243
return _is_agent_email(email) or _norm_email(email).endswith(f"@{PUBLIC_DEMO_DOMAIN}")
244244

245245

246-
# ---- the email-domain blocklist: throwaway mail and abusive signup domains ----------------------
247-
# A new team is created with a promotional balance (`application.signup._grant_signup_promo`), which
248-
# makes bulk registration on throwaway addresses worth someone's while. Two tiers, one classifier.
249-
# Tier 1 is CODE: domains confirmed abusive in our own data, plus substring rules that catch
250-
# throwaway-mail providers no static list has seen yet. Tier 2 is OPS:
251-
# `TREG_BLOCKED_EMAIL_DOMAINS`, unioned in, so the next domain is a dashboard edit made the minute it
252-
# appears, not a deploy. Three rules, each of which exists because the obvious implementation is
253-
# wrong:
254-
# - match the DOMAIN only, never the whole address. Matching the address false-flags real users
255-
# whose USERNAME happens to contain a keyword (`tempmail@gmail.com` is a real person).
246+
# ---- the email-domain blocklist ------------------------------------------------------------------
247+
# Entirely configuration: `TREG_BLOCKED_EMAIL_DOMAINS` and nothing else. An unset variable blocks
248+
# nothing, which is the default. Two rules:
249+
# - match the DOMAIN only, never the whole address. Matching the address false-flags real people
250+
# whose USERNAME happens to contain a listed string.
256251
# - walk parent domains, whole labels off the front only and never the bare last label, because
257-
# registering `<random>.<blocked-root>` is otherwise a one-line bypass. The walk is safe because
258-
# no entry is a bare public suffix, which `config._blocked_email_domains` enforces for the ops
259-
# tier by dropping dotless entries.
260-
# - a PURE classifier: refusing, logging and skipping a perk are the caller's decisions
261-
# (`application.signup.blocked_email`).
262-
BLOCKED_EMAIL_DOMAINS: frozenset[str] = frozenset({
263-
# Confirmed abusive in our own data: bulk registration only, no legitimate account on any of them.
264-
"uberip.com",
265-
"westcast-systems.com",
266-
"mailfox.win",
267-
"yopmail.com",
268-
# Free `.my.id` subdomains are handed out publicly. Listed as the parent so the walk catches
269-
# `<anything>.my.id`.
270-
"my.id",
271-
})
272-
# Substring rules on the domain: throwaway-mail providers name themselves.
273-
BLOCKED_EMAIL_KEYWORDS: tuple[str, ...] = (
274-
"tempmail", "temp-mail", "mailinator", "guerrilla", "throwaway", "10minute", "trashmail",
275-
"yopmail", "sharklasers", "dispostable", "getnada", "maildrop", "moakt", "mohmal",
276-
"emailondeck", "fakemail",
277-
)
252+
# registering `<random>.<listed-domain>` is otherwise a one-line bypass. The walk is safe
253+
# because no entry can be a bare public suffix: `config._blocked_email_domains` drops dotless
254+
# entries, so a typed `com` cannot refuse the world.
255+
# A PURE classifier: refusing, logging and skipping a perk are the caller's decisions
256+
# (`application.signup.blocked_email`).
257+
#
258+
# There is deliberately no list in the code. A blocklist is a speed bump — a new domain costs the
259+
# other side minutes — so its only value is being editable in the same minutes, which a deploy is
260+
# not. Substring rules on the domain were tried and removed: measured against a public
261+
# throwaway-domain corpus they matched 0.17% of it, added nothing over the exact entries, and
262+
# refused a real company whose domain merely contained one of the strings.
278263

279264

280265
def _email_domain(email: str) -> str:
@@ -284,15 +269,16 @@ def _email_domain(email: str) -> str:
284269

285270

286271
def _is_blocked_email(email: str) -> bool:
287-
"""Pure classifier: is this address on a blocked domain, on a subdomain of one, or on a domain
288-
that names itself a throwaway? An empty ops list leaves the code tier alone in force."""
272+
"""Pure classifier: is this address on a configured domain, or on a subdomain of one? An unset
273+
`TREG_BLOCKED_EMAIL_DOMAINS` blocks nothing."""
274+
blocked = get_settings().blocked_email_domain_set
275+
if not blocked:
276+
return False
289277
domain = _email_domain(email)
290278
if not domain:
291279
return False
292-
ops = get_settings().blocked_email_domain_set
293280
labels = domain.split(".")
294281
for i in range(len(labels) - 1): # every parent domain, never the bare last label
295-
candidate = ".".join(labels[i:])
296-
if candidate in BLOCKED_EMAIL_DOMAINS or candidate in ops:
282+
if ".".join(labels[i:]) in blocked:
297283
return True
298-
return any(keyword in domain for keyword in BLOCKED_EMAIL_KEYWORDS)
284+
return False

0 commit comments

Comments
 (0)