feat(admin): accept domain entries in the email blocklist (#3129)

This commit is contained in:
Hampus
2026-10-02 18:00:38 +02:00
committed by GitHub
parent b54016653b
commit 1abde06824
8 changed files with 106 additions and 15 deletions
+6 -2
View File
@@ -13427,7 +13427,10 @@
"BanEmailRequest": { "BanEmailRequest": {
"type": "object", "type": "object",
"properties": { "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"] "required": ["email"]
}, },
@@ -13436,7 +13439,7 @@
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}}, "properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
"required": ["ip"] "required": ["ip"]
}, },
"EmailType": {"type": "string"}, "EmailBlocklistEntryType": {"type": "string"},
"CheckAvatarHashRequest": { "CheckAvatarHashRequest": {
"type": "object", "type": "object",
"properties": {"hashes": {"minItems": 1, "maxItems": 1000, "type": "array", "items": {"type": "string"}}}, "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"} {"name": "PERKS_DISABLED", "value": "256", "description": "User has temporarily disabled premium perks"}
] ]
}, },
"EmailType": {"type": "string"},
"AdminRelationshipEntrySchema": { "AdminRelationshipEntrySchema": {
"type": "object", "type": "object",
"properties": { "properties": {
+1 -1
View File
@@ -11,7 +11,7 @@ impl AdminApiClient {
"email", "email",
generated_types::AdminBlocklistEntryCreateRequest::from( generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanEmailRequest { generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()), email: generated_types::EmailBlocklistEntryType::from(email.to_owned()),
}, },
), ),
audit_log_reason, audit_log_reason,
+3 -3
View File
@@ -37,10 +37,10 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
BanConfig { BanConfig {
title: "Email Bans", title: "Email Bans",
route: "/email-bans", route: "/email-bans",
input_label: "Email Address", input_label: "Email Address or Domain",
input_name: "email", input_name: "email",
input_type: "email", input_type: "text",
placeholder: "[email protected]", placeholder: "[email protected] or @example.com",
entity_name: "Email", entity_name: "Email",
active_page: "email-bans", active_page: "email-bans",
show_bulk_tools: false, show_bulk_tools: false,
+21 -3
View File
@@ -46,6 +46,20 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'), where: BannedEmails.where.eq('email_lower'),
}); });
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select(); const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
function getEmailBlocklistKeys(email: string): Array<string> {
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({ const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
where: BannedPhrases.where.eq('phrase'), where: BannedPhrases.where.eq('phrase'),
}); });
@@ -224,11 +238,15 @@ export class AdminRepository implements IAdminRepository {
} }
async isEmailBanned(email: string): Promise<boolean> { async isEmailBanned(email: string): Promise<boolean> {
const emailLower = email.toLowerCase(); for (const key of getEmailBlocklistKeys(email)) {
const result = await fetchOne<{ const result = await fetchOne<{
email_lower: string; email_lower: string;
}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower})); }>(IS_EMAIL_BANNED_QUERY.bind({email_lower: key}));
return !!result; if (result) {
return true;
}
}
return false;
} }
async banEmail(email: string): Promise<void> { async banEmail(email: string): Promise<void> {
@@ -67,7 +67,8 @@ const BLOCKLIST_CATALOG = [
}, },
{ {
list_type: 'email' as const, 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', value_field: 'email',
fields: [], fields: [],
scoped: false, scoped: false,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import { import {
clearTestEmails, clearTestEmails,
createAuthHarness, createAuthHarness,
@@ -36,7 +37,11 @@ describe('Email blocklist at signup and email change', () => {
beforeEach(async () => { beforeEach(async () => {
await harness.reset(); await harness.reset();
await clearTestEmails(harness); 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 () => { afterAll(async () => {
await harness?.shutdown(); await harness?.shutdown();
@@ -74,6 +79,52 @@ describe('Email blocklist at signup and email change', () => {
expectGenericEmailError(json, 'email'); expectGenericEmailError(json, 'email');
}); });
function register(email: string) {
return createBuilder<ValidationErrorBody>(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 () => { it('still registers an address that is not blocklisted', async () => {
await blocklist(createUniqueEmail('blocked-other')); await blocklist(createUniqueEmail('blocked-other'));
await createTestAccount(harness); await createTestAccount(harness);
@@ -60,7 +60,7 @@ import {
SnowflakeType, SnowflakeType,
withOpenApiType, withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives'; } 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 {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
import {z} from 'zod'; import {z} from 'zod';
@@ -299,7 +299,9 @@ export const BanIpRequest = z.object({
export type BanIpRequest = z.infer<typeof BanIpRequest>; export type BanIpRequest = z.infer<typeof BanIpRequest>;
export const BanEmailRequest = z.object({ 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<typeof BanEmailRequest>; export type BanEmailRequest = z.infer<typeof BanEmailRequest>;
@@ -50,6 +50,21 @@ export const EmailType = withOpenApiType(
}, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART), }, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART),
'EmailType', '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( export const DiscriminatorType = withOpenApiType(
z z
.union([z.string(), z.number()]) .union([z.string(), z.number()])