Skip to content

Commit 2a1e4e2

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): add a configurable email-domain block list for sign-in
Adds a `BLOCKED_EMAIL_DOMAINS` setting for self-hosted instances. It takes a comma-separated list of email domains, including their subdomains, that can't sign in or sign up. A magic-link request for a blocked address shows the usual "check your email" page, but no email is sent. GitHub, Google and SSO sign-in, magic-link clicks, and email changes to a blocked address are refused. It's empty by default, so nothing changes unless you set it. Mono-RevId: 817556233067e86f51a3daef4cb429ce69244c37
1 parent eb04c31 commit 2a1e4e2

8 files changed

Lines changed: 112 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted instances can block sign-in and sign-up from specific email domains with the new `BLOCKED_EMAIL_DOMAINS` setting.

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ const EnvironmentSchema = z
402402
.string()
403403
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
404404
.optional(),
405+
BLOCKED_EMAIL_DOMAINS: z.string().optional(),
405406
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
406407
// Instance-level kill switch for the admin dashboard and user impersonation.
407408
ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true),

‎apps/webapp/app/services/email.server.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { env } from "~/env.server";
66
import type { AuthUser } from "./authUser";
77
import { logger } from "./logger.server";
88
import { singleton } from "~/utils/singleton";
9-
import { assertEmailAllowed } from "~/utils/email";
9+
import { assertEmailAllowed, isEmailDomainBlocked } from "~/utils/email";
1010

1111
const client = singleton(
1212
"email-client",
@@ -66,6 +66,11 @@ function buildTransportOptions(alerts?: boolean): MailTransportOptions {
6666
}
6767

6868
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
69+
if (isEmailDomainBlocked(options.emailAddress)) {
70+
logger.info("Magic link not sent: email domain is blocked");
71+
return;
72+
}
73+
6974
assertEmailAllowed(options.emailAddress);
7075

7176
// Auto redirect when in development mode

‎apps/webapp/app/utils/email.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { env } from "~/env.server";
2+
import { emailDomainIsListed } from "./emailDomainList";
23
import { emailMatchesPattern } from "./emailPattern";
34

5+
export function isEmailDomainBlocked(email: string): boolean {
6+
if (!env.BLOCKED_EMAIL_DOMAINS) {
7+
return false;
8+
}
9+
10+
return emailDomainIsListed(env.BLOCKED_EMAIL_DOMAINS, email);
11+
}
12+
413
export function assertEmailAllowed(email: string) {
14+
if (isEmailDomainBlocked(email)) {
15+
throw new Error("This email address isn't allowed to sign in on this instance.");
16+
}
17+
518
if (!env.WHITELISTED_EMAILS) {
619
return;
720
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Whether an email's domain is in an operator-supplied list of domains
3+
* (BLOCKED_EMAIL_DOMAINS). The list is comma or whitespace separated, and an
4+
* entry matches its own domain and every subdomain of it, case-insensitively.
5+
*
6+
* Dependency-free so it can be tested directly; callers pass the list from `env`.
7+
*/
8+
export function emailDomainIsListed(domainList: string, email: string): boolean {
9+
const domain = normalizeDomain(email.slice(email.lastIndexOf("@") + 1));
10+
if (!domain) {
11+
return false;
12+
}
13+
14+
return parseDomainList(domainList).some(
15+
(entry) => domain === entry || domain.endsWith(`.${entry}`)
16+
);
17+
}
18+
19+
function parseDomainList(domainList: string): string[] {
20+
return domainList
21+
.split(/[\s,]+/)
22+
.map((entry) => normalizeDomain(entry.replace(/^[@.]+/, "")))
23+
.filter((entry) => entry.length > 0);
24+
}
25+
26+
function normalizeDomain(domain: string): string {
27+
return domain.trim().toLowerCase().replace(/\.+$/, "");
28+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { emailDomainIsListed } from "../app/utils/emailDomainList.js";
3+
4+
describe("emailDomainIsListed", () => {
5+
it("matches an address at a listed domain", () => {
6+
expect(emailDomainIsListed("blocked.example", "bot@blocked.example")).toBe(true);
7+
});
8+
9+
it("matches subdomains of a listed domain", () => {
10+
expect(emailDomainIsListed("blocked.example", "bot@mail.blocked.example")).toBe(true);
11+
expect(emailDomainIsListed("blocked.example", "bot@a.b.blocked.example")).toBe(true);
12+
});
13+
14+
it("ignores case and surrounding whitespace in both the list and the address", () => {
15+
expect(emailDomainIsListed(" Blocked.Example ", "Bot@BLOCKED.example")).toBe(true);
16+
});
17+
18+
it("accepts comma and whitespace separated lists", () => {
19+
const list = "one.example, two.example\nthree.example four.example";
20+
expect(emailDomainIsListed(list, "a@one.example")).toBe(true);
21+
expect(emailDomainIsListed(list, "a@two.example")).toBe(true);
22+
expect(emailDomainIsListed(list, "a@three.example")).toBe(true);
23+
expect(emailDomainIsListed(list, "a@four.example")).toBe(true);
24+
expect(emailDomainIsListed(list, "a@five.example")).toBe(false);
25+
});
26+
27+
it("accepts entries written with a leading @ or dot", () => {
28+
expect(emailDomainIsListed("@blocked.example", "bot@blocked.example")).toBe(true);
29+
expect(emailDomainIsListed(".blocked.example", "bot@sub.blocked.example")).toBe(true);
30+
});
31+
32+
it("matches a fully qualified domain with a trailing dot", () => {
33+
expect(emailDomainIsListed("blocked.example", "bot@blocked.example.")).toBe(true);
34+
});
35+
36+
it("does not match look-alike domains", () => {
37+
expect(emailDomainIsListed("blocked.example", "user@notblocked.example")).toBe(false);
38+
expect(emailDomainIsListed("blocked.example", "user@blocked.example.attacker.example")).toBe(
39+
false
40+
);
41+
expect(emailDomainIsListed("blocked.example", "blocked.example@other.example")).toBe(false);
42+
});
43+
44+
it("does not let a listed domain block its parent", () => {
45+
expect(emailDomainIsListed("mail.company.example", "user@company.example")).toBe(false);
46+
});
47+
48+
it("matches nothing when the list is empty or only separators", () => {
49+
expect(emailDomainIsListed("", "user@company.example")).toBe(false);
50+
expect(emailDomainIsListed(" , ,", "user@company.example")).toBe(false);
51+
});
52+
53+
it("matches nothing for an address without a domain", () => {
54+
expect(emailDomainIsListed("blocked.example", "user@")).toBe(false);
55+
});
56+
});

‎docs/self-hosting/env/webapp.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ mode: "wide"
4242
| `REDIS_TLS_DISABLED` | No | — | Disable Redis TLS. |
4343
| **Auth** | | | |
4444
| `WHITELISTED_EMAILS` | No | — | Whitelisted emails regex. |
45+
| `BLOCKED_EMAIL_DOMAINS` | No | — | Comma-separated email domains that can't sign in or sign up, including their subdomains. |
4546
| `LOGIN_RATE_LIMITS_ENABLED` | No | true | Enable rate limiting on magic-link login. |
4647
| `AUTH_GITHUB_CLIENT_ID` | No | — | GitHub client ID. |
4748
| `AUTH_GITHUB_CLIENT_SECRET` | No | — | GitHub client secret. |

‎hosting/docker/webapp/docker-compose.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ services:
5858
DIRECT_URL: ${DIRECT_URL:-postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/main?schema=public&sslmode=disable}
5959
SESSION_SECRET: ${SESSION_SECRET}
6060
MAGIC_LINK_SECRET: ${MAGIC_LINK_SECRET}
61+
BLOCKED_EMAIL_DOMAINS: ${BLOCKED_EMAIL_DOMAINS:-}
6162
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
6263
PROVIDER_SECRET: ${PROVIDER_SECRET}
6364
COORDINATOR_SECRET: ${COORDINATOR_SECRET}

0 commit comments

Comments
 (0)