Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
**/dist/
**/dist-test/
**/node_modules/
**/.nyc_output
/.idea
Expand Down
3 changes: 2 additions & 1 deletion modules/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"build": "rimraf dist && tsc",
"postbuild": "copyfiles -u 1 src/**/*.proto src/*.proto src/**/*.json ./dist/",
"build:docker": "docker build -t ghcr.io/conduitplatform/authentication:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/authentication:latest",
"generateTypes": "sh build.sh"
"generateTypes": "sh build.sh",
"test": "tsc -p tsconfig.test.json && copyfiles -u 1 src/data/*.json dist-test/ && node --test dist-test/utils/emailRestrictions.test.js"
},
"license": "ISC",
"directories": {
Expand Down
11 changes: 5 additions & 6 deletions modules/authentication/src/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ConduitGrpcSdk,
DatabaseProvider,
GrpcCallback,
GrpcError,
GrpcRequest,
HealthCheckStatus,
Indexable,
Expand Down Expand Up @@ -294,12 +295,7 @@ export default class Authentication extends ManagedModule<Config> {
if (user) {
return callback({ code: status.ALREADY_EXISTS, message: 'User already exists' });
}
if (AuthUtils.invalidEmailAddress(email)) {
return callback({
code: status.INVALID_ARGUMENT,
message: 'Invalid email address provided',
});
}
AuthUtils.assertValidEmail(email);
const hashedPassword = await AuthUtils.hashPassword(password);
const anonymousUserId = call.request.anonymousId;
if (!anonymousUserId) {
Expand Down Expand Up @@ -367,6 +363,9 @@ export default class Authentication extends ManagedModule<Config> {
}
return callback(null, { password });
} catch (e) {
if (e instanceof GrpcError) {
return callback({ code: e.code, message: e.message });
}
return callback({ code: status.INTERNAL, message: (e as Error).message });
}
}
Expand Down
2 changes: 2 additions & 0 deletions modules/authentication/src/admin/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@conduitplatform/grpc-sdk';
import { status } from '@grpc/grpc-js';
import { AuthUtils } from '../utils/index.js';
import { assertEmailAllowed } from '../utils/emailRestrictions.js';
import { isNil } from 'lodash-es';
import { User } from '../models/index.js';
import escapeStringRegexp from 'escape-string-regexp';
Expand Down Expand Up @@ -93,6 +94,7 @@ export class UserAdmin {
if (!isNil(duplicateEmail)) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Email already exists');
}
assertEmailAllowed(email);
}
let twoFaMethod: string | undefined;
if (hasTwoFA) {
Expand Down
51 changes: 51 additions & 0 deletions modules/authentication/src/config/emailRestrictions.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export default {
emailRestrictions: {
enabled: {
doc: 'Defines if email restrictions are enforced on new email intake',
format: 'Boolean',
default: false,
},
blockDisposableEmails: {
doc: 'Defines if disposable email domains should be blocked',
format: 'Boolean',
default: true,
},
blockPlusAddressing: {
doc: 'Defines if plus addressing in the local part of an email should be blocked',
format: 'Boolean',
default: true,
},
blockedAddresses: {
doc: 'Exact email addresses that are not allowed',
format: 'Array',
children: {
format: 'String',
},
default: [],
},
blockedDomains: {
doc: 'Email domains that are not allowed, including subdomains',
format: 'Array',
children: {
format: 'String',
},
default: [],
},
allowedAddresses: {
doc: 'Exact email addresses that override block, disposable, and plus rules, but not the reserved anonymous.com domain',
format: 'Array',
children: {
format: 'String',
},
default: [],
},
allowedDomains: {
doc: 'Email domains that override block, disposable, and plus rules, including subdomains, but not the reserved anonymous.com domain',
format: 'Array',
children: {
format: 'String',
},
default: [],
},
},
};
2 changes: 2 additions & 0 deletions modules/authentication/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import appleConfig from './apple.config.js';
import twitterConfig from './twitter.config.js';
import teamsConfig from './teams.config.js';
import metamaskConfig from './metamask.config.js';
import emailRestrictionsConfig from './emailRestrictions.config.js';

const AppConfigSchema = {
...DefaultConfig,
Expand All @@ -31,6 +32,7 @@ const AppConfigSchema = {
...slackConfig,
...tokenConfig,
...localConfig,
...emailRestrictionsConfig,
...magicLinkConfig,
...gitlabConfig,
...appleConfig,
Expand Down

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions modules/authentication/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ export const errors = {
message: 'Invalid invitation token',
description: 'The provided invitation token is invalid',
},
EMAIL_NOT_ALLOWED: {
conduitCode: 'EMAIL_NOT_ALLOWED',
grpcCode: status.INVALID_ARGUMENT,
message: 'This email address is not allowed',
description: 'The provided email address is blocked by email restrictions',
},
} as const satisfies Record<string, ModuleErrorDefinition>;
14 changes: 5 additions & 9 deletions modules/authentication/src/handlers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
errors.USER_EXISTS,
errors.INVITATION_REQUIRED,
errors.INVALID_INVITATION,
errors.EMAIL_NOT_ALLOWED,
],
},
new ConduitRouteReturnDefinition('RegisterResponse', User.name),
Expand Down Expand Up @@ -175,6 +176,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
redirectUri: ConduitString.Optional,
},
middlewares: ['authMiddleware', 'denyAnonymousMiddleware'],
errors: [errors.EMAIL_NOT_ALLOWED],
},
new ConduitRouteReturnDefinition('ChangeEmailResponse', 'String'),
this.changeEmail.bind(this),
Expand Down Expand Up @@ -292,10 +294,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
return token?.data?.userData;
});
}
const invalidAddress = AuthUtils.invalidEmailAddress(email);
if (invalidAddress) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid email address provided');
}
AuthUtils.assertValidEmail(email, 'module');

let user: User | null = await User.getInstance().findOne({ email });
if (!isNil(user))
Expand Down Expand Up @@ -543,10 +542,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
'The new email can not be the same as the old email',
);
}
const invalidAddress = AuthUtils.invalidEmailAddress(newEmail);
if (invalidAddress) {
throw new GrpcError(status.INVALID_ARGUMENT, 'Invalid email address provided');
}
AuthUtils.assertValidEmail(newEmail, 'module');
const dupEmailUser = await User.getInstance().findOne({ email: newEmail });
if (dupEmailUser) {
throw new GrpcError(status.ALREADY_EXISTS, 'Email address already taken');
Expand Down Expand Up @@ -637,7 +633,7 @@ export class LocalHandlers implements IAuthenticationStrategy {
}

async verifyChangeEmail(call: ParsedRouterRequest): Promise<UnparsedRouterResponse> {
const { verificationToken } = call.request.params.verificationToken;
const verificationToken = call.request.params.verificationToken;
const config = ConfigController.getInstance().config;
const token: Token | null = await Token.getInstance().findOne(
{
Expand Down
4 changes: 4 additions & 0 deletions modules/authentication/src/handlers/oauth2/OAuth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
RoutingManager,
} from '@conduitplatform/module-tools';
import { AuthUtils } from '../../utils/index.js';
import { assertEmailAllowed } from '../../utils/emailRestrictions.js';

export abstract class OAuth2<T, S extends OAuth2Settings>
implements IAuthenticationStrategy
Expand Down Expand Up @@ -298,6 +299,9 @@ export abstract class OAuth2<T, S extends OAuth2Settings>
if (!user.isVerified) user.isVerified = true;
user = await User.getInstance().findByIdAndUpdate(user._id, user);
} else {
if (payload.email) {
assertEmailAllowed(payload.email);
}
if (anonymousUser) {
return (await User.getInstance().findByIdAndUpdate(anonymousUser._id, {
email: payload.email,
Expand Down
4 changes: 4 additions & 0 deletions modules/authentication/src/handlers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Team as TeamAuthz } from '../authz/index.js';
import { TeamInviteTemplate } from '../templates/index.js';
import { status } from '@grpc/grpc-js';
import { AuthUtils } from '../utils/index.js';
import { assertEmailAllowed } from '../utils/emailRestrictions.js';
import { IAuthenticationStrategy } from '../interfaces/index.js';
import { TokenType } from '../constants/index.js';
import { v4 as uuid } from 'uuid';
Expand Down Expand Up @@ -577,6 +578,9 @@ export class TeamsHandler implements IAuthenticationStrategy {
'You do not have permission to invite users to this team',
);
}
if (email) {
assertEmailAllowed(email);
}

// Delete any existing invite for the same email and team
await Token.getInstance().deleteOne({
Expand Down
167 changes: 167 additions & 0 deletions modules/authentication/src/utils/emailRestrictions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
evaluateEmailRestrictions,
type DisposableEmailSets,
type EmailRestrictionsConfig,
} from './emailRestrictions.js';

function config(
overrides: Partial<EmailRestrictionsConfig> = {},
): EmailRestrictionsConfig {
return {
enabled: true,
blockDisposableEmails: true,
blockPlusAddressing: true,
blockedAddresses: [],
blockedDomains: [],
allowedAddresses: [],
allowedDomains: [],
...overrides,
};
}

const disposable: DisposableEmailSets = {
domains: new Set(['mailinator.com']),
wildcards: new Set(['anonaddy.com']),
};

describe('evaluateEmailRestrictions', () => {
it('allows all addresses when the master switch is off except reserved anonymous domains', () => {
const result = evaluateEmailRestrictions(
'user+tag@mailinator.com',
config({ enabled: false }),
disposable,
);
assert.deepEqual(result, { allowed: true });

assert.deepEqual(
evaluateEmailRestrictions('attacker@anonymous.com', config({ enabled: false }), disposable),
{ allowed: false, reason: 'reserved anonymous domain' },
);
});

it('denies reserved anonymous.com even when allowlisted', () => {
const result = evaluateEmailRestrictions(
'attacker@anonymous.com',
config({ allowedAddresses: ['attacker@anonymous.com'] }),
disposable,
);
assert.deepEqual(result, { allowed: false, reason: 'reserved anonymous domain' });
});

it('denies subdomains of anonymous.com', () => {
const result = evaluateEmailRestrictions(
'user@mail.anonymous.com',
config({ enabled: false }),
disposable,
);
assert.deepEqual(result, { allowed: false, reason: 'reserved anonymous domain' });
});

it('denies plus addressing in the local part', () => {
const result = evaluateEmailRestrictions(
'user+tag@example.com',
config(),
disposable,
);
assert.deepEqual(result, { allowed: false, reason: 'plus addressing' });
});

it('denies an exact blocked address', () => {
const result = evaluateEmailRestrictions(
'blocked@example.com',
config({
blockPlusAddressing: false,
blockedAddresses: ['blocked@example.com'],
}),
disposable,
);
assert.deepEqual(result, { allowed: false, reason: 'blocked address' });
});

it('denies suffix domain matches without treating a bare TLD as a suffix', () => {
const blocked = config({
blockPlusAddressing: false,
blockedDomains: ['evil.com', 'com'],
});
assert.deepEqual(
evaluateEmailRestrictions('user@mail.evil.com', blocked, disposable),
{ allowed: false, reason: 'blocked domain' },
);
assert.deepEqual(
evaluateEmailRestrictions('user@evil.com', blocked, disposable),
{ allowed: false, reason: 'blocked domain' },
);
assert.deepEqual(
evaluateEmailRestrictions('user@notevil.com', blocked, disposable),
{ allowed: true },
);
assert.deepEqual(
evaluateEmailRestrictions('user@example.com', blocked, disposable),
{ allowed: true },
);
assert.deepEqual(
evaluateEmailRestrictions('user@com', blocked, disposable),
{ allowed: true },
);
});

it('allows allowlisted addresses and domains to override other rules', () => {
const restricted = config({
blockedDomains: ['example.com'],
allowedAddresses: ['vip+tag@blocked.com'],
allowedDomains: ['trusted.example.com'],
});
assert.deepEqual(
evaluateEmailRestrictions('vip+tag@blocked.com', restricted, disposable),
{ allowed: true },
);
assert.deepEqual(
evaluateEmailRestrictions('user@mail.trusted.example.com', restricted, disposable),
{ allowed: true },
);
assert.deepEqual(
evaluateEmailRestrictions('user@example.com', restricted, disposable),
{ allowed: false, reason: 'blocked domain' },
);
});

it('ignores non-string list entries', () => {
const blocked = config({
blockPlusAddressing: false,
blockedDomains: ['evil.com', null as unknown as string, 42 as unknown as string],
});
assert.deepEqual(
evaluateEmailRestrictions('user@evil.com', blocked, disposable),
{ allowed: false, reason: 'blocked domain' },
);
});

it('denies disposable domains and wildcard parents', () => {
assert.deepEqual(
evaluateEmailRestrictions(
'user@mailinator.com',
config({ blockPlusAddressing: false }),
disposable,
),
{ allowed: false, reason: 'disposable domain' },
);
assert.deepEqual(
evaluateEmailRestrictions(
'user@mail.anonaddy.com',
config({ blockPlusAddressing: false }),
disposable,
),
{ allowed: false, reason: 'disposable domain' },
);
assert.deepEqual(
evaluateEmailRestrictions(
'user@example.com',
config({ blockPlusAddressing: false }),
disposable,
),
{ allowed: true },
);
});
});
Loading