diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index b00536d88..c6b2fe3bb 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -13427,7 +13427,10 @@ "BanEmailRequest": { "type": "object", "properties": { - "email": {"description": "Email address to ban", "allOf": [{"$ref": "#/components/schemas/EmailType"}]} + "email": { + "description": "Email address to ban, or a domain written as @example.com to ban every address at it and its subdomains", + "allOf": [{"$ref": "#/components/schemas/EmailBlocklistEntryType"}] + } }, "required": ["email"] }, @@ -13436,7 +13439,7 @@ "properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}}, "required": ["ip"] }, - "EmailType": {"type": "string"}, + "EmailBlocklistEntryType": {"type": "string"}, "CheckAvatarHashRequest": { "type": "object", "properties": {"hashes": {"minItems": 1, "maxItems": 1000, "type": "array", "items": {"type": "string"}}}, @@ -16036,6 +16039,7 @@ {"name": "PERKS_DISABLED", "value": "256", "description": "User has temporarily disabled premium perks"} ] }, + "EmailType": {"type": "string"}, "AdminRelationshipEntrySchema": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/bans.rs b/fluxer_admin/src/api/bans.rs index 86f345e95..9c0c53456 100644 --- a/fluxer_admin/src/api/bans.rs +++ b/fluxer_admin/src/api/bans.rs @@ -11,7 +11,7 @@ impl AdminApiClient { "email", generated_types::AdminBlocklistEntryCreateRequest::from( generated_types::BanEmailRequest { - email: generated_types::EmailType::from(email.to_owned()), + email: generated_types::EmailBlocklistEntryType::from(email.to_owned()), }, ), audit_log_reason, diff --git a/fluxer_admin/src/templates/pages/bans.rs b/fluxer_admin/src/templates/pages/bans.rs index 7b952de92..5e22ce2a1 100644 --- a/fluxer_admin/src/templates/pages/bans.rs +++ b/fluxer_admin/src/templates/pages/bans.rs @@ -37,10 +37,10 @@ pub const BAN_CONFIGS: &[BanConfig] = &[ BanConfig { title: "Email Bans", route: "/email-bans", - input_label: "Email Address", + input_label: "Email Address or Domain", input_name: "email", - input_type: "email", - placeholder: "user@example.com", + input_type: "text", + placeholder: "user@example.com or @example.com", entity_name: "Email", active_page: "email-bans", show_bulk_tools: false, diff --git a/fluxer_api/src/api/admin/AdminRepository.ts b/fluxer_api/src/api/admin/AdminRepository.ts index e5317a8b2..bf3da79b1 100644 --- a/fluxer_api/src/api/admin/AdminRepository.ts +++ b/fluxer_api/src/api/admin/AdminRepository.ts @@ -46,6 +46,20 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({ where: BannedEmails.where.eq('email_lower'), }); const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select(); + +function getEmailBlocklistKeys(email: string): Array { + const emailLower = email.trim().toLowerCase(); + const atIndex = emailLower.lastIndexOf('@'); + if (atIndex <= 0) { + return [emailLower]; + } + const labels = emailLower.slice(atIndex + 1).split('.'); + const keys = [emailLower]; + for (let index = 0; index < labels.length - 1; index++) { + keys.push(`@${labels.slice(index).join('.')}`); + } + return keys; +} const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({ where: BannedPhrases.where.eq('phrase'), }); @@ -224,11 +238,15 @@ export class AdminRepository implements IAdminRepository { } async isEmailBanned(email: string): Promise { - const emailLower = email.toLowerCase(); - const result = await fetchOne<{ - email_lower: string; - }>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower})); - return !!result; + for (const key of getEmailBlocklistKeys(email)) { + const result = await fetchOne<{ + email_lower: string; + }>(IS_EMAIL_BANNED_QUERY.bind({email_lower: key})); + if (result) { + return true; + } + } + return false; } async banEmail(email: string): Promise { diff --git a/fluxer_api/src/api/admin/controllers/BanAdminController.ts b/fluxer_api/src/api/admin/controllers/BanAdminController.ts index 25b7c0787..774079b25 100644 --- a/fluxer_api/src/api/admin/controllers/BanAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BanAdminController.ts @@ -67,7 +67,8 @@ const BLOCKLIST_CATALOG = [ }, { list_type: 'email' as const, - description: 'Email addresses that cannot be used to register or be set on an account.', + description: + 'Email addresses that cannot be used to register or be set on an account. An entry written as @example.com covers every address at that domain and its subdomains.', value_field: 'email', fields: [], scoped: false, diff --git a/fluxer_api/src/api/auth/tests/EmailBlocklistEnforcement.test.ts b/fluxer_api/src/api/auth/tests/EmailBlocklistEnforcement.test.ts index 7cb96d767..e10f9a02b 100644 --- a/fluxer_api/src/api/auth/tests/EmailBlocklistEnforcement.test.ts +++ b/fluxer_api/src/api/auth/tests/EmailBlocklistEnforcement.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {randomUUID} from 'node:crypto'; import { clearTestEmails, createAuthHarness, @@ -36,7 +37,11 @@ describe('Email blocklist at signup and email change', () => { beforeEach(async () => { await harness.reset(); await clearTestEmails(harness); - admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'ban:email:add']); + admin = await setUserACLs(harness, await createTestAccount(harness), [ + 'admin:authenticate', + 'ban:email:add', + 'ban:email:check', + ]); }); afterAll(async () => { await harness?.shutdown(); @@ -74,6 +79,52 @@ describe('Email blocklist at signup and email change', () => { expectGenericEmailError(json, 'email'); }); + function register(email: string) { + return createBuilder(harness, '') + .post('/auth/register') + .body({ + email, + username: createUniqueUsername('domain'), + global_name: TEST_USER_DATA.DEFAULT_GLOBAL_NAME, + password: TEST_CREDENTIALS.STRONG_PASSWORD, + date_of_birth: TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH, + consent: true, + }); + } + + it('refuses registration at a blocklisted domain and its subdomains', async () => { + const domain = `${randomUUID()}.test`; + await blocklist(`@${domain.toUpperCase()}`); + for (const email of [`someone@${domain}`, `SOMEONE@MAIL.${domain.toUpperCase()}`]) { + const {json} = await register(email).expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY').executeWithResponse(); + expectGenericEmailError(json, 'email'); + } + }); + + it('does not extend a domain entry to unrelated domains', async () => { + const domain = `${randomUUID()}.test`; + await blocklist(`@${domain}`); + await register(`someone@not${domain}`).execute(); + await register(`someone@${domain}.example`).execute(); + }); + + it('reports a domain entry through the blocklist check', async () => { + const domain = `${randomUUID()}.test`; + await blocklist(`@${domain}`); + const {banned} = await createBuilder<{banned: boolean}>(harness, admin.token) + .get(`/admin/blocklists/email/entries/${encodeURIComponent(`@${domain}`)}`) + .execute(); + expect(banned).toBe(true); + }); + + it('rejects a malformed domain entry', async () => { + await createBuilder(harness, admin.token) + .post('/admin/blocklists/email/entries') + .body({email: '@not a domain'}) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + }); + it('still registers an address that is not blocklisted', async () => { await blocklist(createUniqueEmail('blocked-other')); await createTestAccount(harness); diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index c2c3ade14..0e1fd55b6 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -60,7 +60,7 @@ import { SnowflakeType, withOpenApiType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; -import {EmailType} from '@fluxer/schema/src/primitives/UserValidators'; +import {EmailBlocklistEntryType} from '@fluxer/schema/src/primitives/UserValidators'; import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import {z} from 'zod'; @@ -299,7 +299,9 @@ export const BanIpRequest = z.object({ export type BanIpRequest = z.infer; export const BanEmailRequest = z.object({ - email: EmailType.describe('Email address to ban'), + email: EmailBlocklistEntryType.describe( + 'Email address to ban, or a domain written as @example.com to ban every address at it and its subdomains', + ), }); export type BanEmailRequest = z.infer; diff --git a/packages/schema/src/primitives/UserValidators.ts b/packages/schema/src/primitives/UserValidators.ts index a8f386622..34e3a3563 100644 --- a/packages/schema/src/primitives/UserValidators.ts +++ b/packages/schema/src/primitives/UserValidators.ts @@ -50,6 +50,21 @@ export const EmailType = withOpenApiType( }, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART), 'EmailType', ); + +const EMAIL_BLOCKLIST_DOMAIN_REGEX = + /^@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; + +export const EmailBlocklistEntryType = withOpenApiType( + z + .string() + .overwrite(normalizeString) + .refine( + (value: string) => + EMAIL_BLOCKLIST_DOMAIN_REGEX.test(value) ? value.length <= 254 : EmailType.safeParse(value).success, + ValidationErrorCodes.INVALID_EMAIL_FORMAT, + ), + 'EmailBlocklistEntryType', +); export const DiscriminatorType = withOpenApiType( z .union([z.string(), z.number()])