mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): accept domain entries in the email blocklist (#3129)
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
|||||||
Reference in New Issue
Block a user