Skip to content

Commit ca37052

Browse files
authored
feat(core): add email blocklist guard (#7368)
* feat(core): add email blocklist guard add email blocllist guard * feat(test): add integration tests add integration tests * fix(test): fix lint error fix lint error * fix: fix eslint module not found error fix eslint module not found error * chore(core): refactor the devFeature condition refactor the devFeature condition
1 parent bfc68d5 commit ca37052

File tree

24 files changed

+546
-11
lines changed

24 files changed

+546
-11
lines changed

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
public-hoist-pattern[]=*eslint*

packages/core/src/libraries/sign-in-experience/email-blocklist-policy.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { type EmailBlocklistPolicy } from '@logto/schemas';
12
import { deduplicate } from '@silverhand/essentials';
23

3-
import RequestError from '../../errors/RequestError/index.js';
4+
import RequestError from '#src/errors/RequestError/index.js';
45

5-
import { parseEmailBlocklistPolicy } from './email-blocklist-policy.js';
6+
import {
7+
parseEmailBlocklistPolicy,
8+
validateEmailAgainstBlocklistPolicy,
9+
} from './email-blocklist-policy.js';
610

711
const invalidCustomBlockList = ['bar', 'bar@foo', '@foo', '@foo.', 'bar@foo.'];
812
const validCustomBlockList = ['[email protected]', '@foo.com', '[email protected]', '[email protected]'];
@@ -29,3 +33,58 @@ describe('validateEmailBlocklistPolicy', () => {
2933
expect(parsed).toEqual({ customBlocklist: deduplicate(validCustomBlockList) });
3034
});
3135
});
36+
37+
describe('validateEmailAgainstBlocklistPolicy', () => {
38+
const emailBlocklistPolicy: EmailBlocklistPolicy = {
39+
blockDisposableAddresses: true,
40+
blockSubaddressing: true,
41+
customBlocklist: ['[email protected]', '@foo.com'],
42+
};
43+
44+
it('should throw if the email uses subaddressing', async () => {
45+
await expect(
46+
validateEmailAgainstBlocklistPolicy(emailBlocklistPolicy, '[email protected]')
47+
).rejects.toMatchError(
48+
new RequestError({
49+
code: 'session.email_blocklist.email_subaddressing_not_allowed',
50+
status: 422,
51+
})
52+
);
53+
});
54+
55+
it('should throw if the email domain is in the custom blocklist', async () => {
56+
const emails = ['[email protected]', '[email protected]'];
57+
58+
for (const email of emails) {
59+
// eslint-disable-next-line no-await-in-loop
60+
await expect(
61+
validateEmailAgainstBlocklistPolicy(emailBlocklistPolicy, email)
62+
).rejects.toMatchError(
63+
new RequestError({
64+
code: 'session.email_blocklist.email_not_allowed',
65+
status: 422,
66+
email,
67+
})
68+
);
69+
}
70+
});
71+
72+
it('should throw if the email address is in the custom blocklist', async () => {
73+
const email = '[email protected]';
74+
await expect(
75+
validateEmailAgainstBlocklistPolicy(emailBlocklistPolicy, email)
76+
).rejects.toMatchError(
77+
new RequestError({
78+
code: 'session.email_blocklist.email_not_allowed',
79+
status: 422,
80+
email,
81+
})
82+
);
83+
});
84+
85+
it('should pass the blocklist policy validation', async () => {
86+
await expect(
87+
validateEmailAgainstBlocklistPolicy(emailBlocklistPolicy, '[email protected]')
88+
).resolves.not.toThrow();
89+
});
90+
});

packages/core/src/libraries/sign-in-experience/email-blocklist-policy.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { emailOrEmailDomainRegex } from '@logto/core-kit';
22
import { type EmailBlocklistPolicy } from '@logto/schemas';
33
import { conditional, deduplicate } from '@silverhand/essentials';
44

5-
import { EnvSet } from '../../env-set/index.js';
6-
import RequestError from '../../errors/RequestError/index.js';
7-
import assertThat from '../../utils/assert-that.js';
5+
import { EnvSet } from '#src/env-set/index.js';
6+
import RequestError from '#src/errors/RequestError/index.js';
7+
import assertThat from '#src/utils/assert-that.js';
88

99
const validateCustomBlockListFormat = (list: string[]) => {
1010
const invalidItems = new Set();
@@ -55,3 +55,64 @@ export const parseEmailBlocklistPolicy = (
5555
...conditional(customBlocklist && { customBlocklist: parseCustomBlocklist(customBlocklist) }),
5656
};
5757
};
58+
59+
/**
60+
* Guard the email address is not in the sign-in experience blocklist. *
61+
*
62+
* @remarks
63+
* - guard disposable email domain if enabled
64+
* - guard email subaddessing if enabled
65+
* - guard custom email address/domain if provided
66+
*
67+
* @remarks
68+
* This validation should be applied to all the client email profile fullment flow.
69+
* - experience API
70+
* - account API
71+
*/
72+
export const validateEmailAgainstBlocklistPolicy = async (
73+
emailBlocklistPolicy: EmailBlocklistPolicy,
74+
email: string
75+
) => {
76+
const { customBlocklist, blockDisposableAddresses, blockSubaddressing } = emailBlocklistPolicy;
77+
const domain = email.split('@')[1];
78+
79+
assertThat(domain, new RequestError('session.email_blocklist.invalid_email'));
80+
81+
// Guard disposable email domain if enabled
82+
if (EnvSet.values.isCloud && blockDisposableAddresses) {
83+
// TODO: call Azure function
84+
}
85+
86+
// Guard email subaddressing if enabled
87+
if (blockSubaddressing) {
88+
const subaddressingRegex = new RegExp(`^.*\\+.*@${domain}$`);
89+
assertThat(
90+
!subaddressingRegex.test(email),
91+
new RequestError({
92+
code: 'session.email_blocklist.email_subaddressing_not_allowed',
93+
status: 422,
94+
})
95+
);
96+
}
97+
98+
// Guard custom email address/domain if provided
99+
if (customBlocklist) {
100+
const isCustomBlocklisted = customBlocklist.some((item) => {
101+
// Guard email domain
102+
if (item.startsWith('@')) {
103+
return domain === item.slice(1);
104+
}
105+
106+
return email === item;
107+
});
108+
109+
assertThat(
110+
!isCustomBlocklisted,
111+
new RequestError({
112+
code: 'session.email_blocklist.email_not_allowed',
113+
status: 422,
114+
email,
115+
})
116+
);
117+
}
118+
};

packages/core/src/routes/experience/classes/experience-interaction.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,14 @@ export default class ExperienceInteraction {
291291
verification: verificationData,
292292
});
293293

294-
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
294+
if (verificationRecord.type !== VerificationType.EnterpriseSso) {
295+
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
296+
}
297+
await this.signInExperienceValidator.guardEmailBlocklist(verificationRecord);
298+
295299
const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
296300

297301
await this.profile.setProfileWithValidation(identifierProfile);
298-
299302
// Save the updated profile data to the interaction storage
300303
await this.save();
301304
}

packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@ import {
88
VerificationType,
99
} from '@logto/schemas';
1010

11+
import { EnvSet } from '#src/env-set/index.js';
1112
import RequestError from '#src/errors/RequestError/index.js';
13+
import { validateEmailAgainstBlocklistPolicy } from '#src/libraries/sign-in-experience/index.js';
1214
import type Libraries from '#src/tenants/Libraries.js';
1315
import type Queries from '#src/tenants/Queries.js';
1416
import assertThat from '#src/utils/assert-that.js';
1517

18+
import { type EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js';
1619
import { type VerificationRecord } from '../verifications/index.js';
1720

1821
const getEmailIdentifierFromVerificationRecord = (verificationRecord: VerificationRecord) => {
1922
switch (verificationRecord.type) {
2023
case VerificationType.Password:
24+
case VerificationType.OneTimeToken:
2125
case VerificationType.EmailVerificationCode:
2226
case VerificationType.PhoneVerificationCode: {
2327
const {
@@ -30,6 +34,10 @@ const getEmailIdentifierFromVerificationRecord = (verificationRecord: Verificati
3034
const { socialUserInfo } = verificationRecord;
3135
return socialUserInfo?.email;
3236
}
37+
case VerificationType.EnterpriseSso: {
38+
const { enterpriseSsoUserInfo } = verificationRecord;
39+
return enterpriseSsoUserInfo?.email;
40+
}
3341
default: {
3442
break;
3543
}
@@ -222,7 +230,9 @@ export class SignInExperienceValidator {
222230
*
223231
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
224232
**/
225-
public async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) {
233+
public async guardSsoOnlyEmailIdentifier(
234+
verificationRecord: Exclude<VerificationRecord, EnterpriseSsoVerification>
235+
) {
226236
const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord);
227237

228238
if (!emailIdentifier) {
@@ -261,6 +271,30 @@ export class SignInExperienceValidator {
261271
throw new RequestError({ code: 'session.captcha_required', status: 422 });
262272
}
263273

274+
/**
275+
* Guard the email address is not in the blocklist.
276+
*
277+
* @remarks
278+
* Use this method to guard the email address or domain is not in the blocklist.
279+
* - guard disposable email domain if enabled
280+
* - guard email subaddessing if enabled
281+
* - guard custom email address/domain if provided
282+
*/
283+
public async guardEmailBlocklist(verificationRecord: VerificationRecord) {
284+
// TODO: Remove this once the dev feature is ready
285+
if (!EnvSet.values.isDevFeaturesEnabled) {
286+
return;
287+
}
288+
289+
const email = getEmailIdentifierFromVerificationRecord(verificationRecord);
290+
if (!email) {
291+
return;
292+
}
293+
294+
const { emailBlocklistPolicy } = await this.getSignInExperienceData();
295+
await validateEmailAgainstBlocklistPolicy(emailBlocklistPolicy, email);
296+
}
297+
264298
/**
265299
* @throws {RequestError} with status 422 if the verification record type is not enabled
266300
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
@@ -308,7 +342,9 @@ export class SignInExperienceValidator {
308342
}
309343
}
310344

311-
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
345+
if (verificationRecord.type !== VerificationType.EnterpriseSso) {
346+
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
347+
}
312348
}
313349

314350
/** Forgot password only supports verification code type verification record */

packages/core/src/routes/experience/classes/profile.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export class Profile {
8787
// Guard SSO only email identifier in verification record (EmailVerificationCode, Social)
8888
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
8989

90+
await this.signInExperienceValidator.guardEmailBlocklist(verificationRecord);
91+
9092
log?.append({
9193
verification: verificationRecord.toJson(),
9294
});
@@ -99,7 +101,6 @@ export class Profile {
99101
if (verificationRecord.type === VerificationType.Social) {
100102
const user = await this.safeGetIdentifiedUser();
101103
const isNewUserIdentity = !user;
102-
103104
// Sync the email and phone to the user profile only for new user identity
104105
const syncedProfile = await verificationRecord.toSyncedProfile(isNewUserIdentity);
105106
this.unsafePrepend(syncedProfile);
@@ -120,6 +121,7 @@ export class Profile {
120121
}
121122

122123
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
124+
123125
this.unsafeSet(profile);
124126
}
125127

0 commit comments

Comments
 (0)