Skip to content
7 changes: 7 additions & 0 deletions packages/@n8n/constants/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export interface LdapConfig {
synchronizationInterval: number; // minutes
searchPageSize: number;
searchTimeout: number;
/**
* Enforce email uniqueness in LDAP directory.
* When enabled, blocks login if multiple LDAP accounts share the same email.
* Prevents privilege escalation via email-based account linking.
*/
enforceEmailUniqueness: boolean;
}

export const LDAP_DEFAULT_CONFIGURATION: LdapConfig = {
Expand All @@ -104,6 +110,7 @@ export const LDAP_DEFAULT_CONFIGURATION: LdapConfig = {
synchronizationInterval: 60,
searchPageSize: 0,
searchTimeout: 60,
enforceEmailUniqueness: true,
};

export { Time } from './time';
Expand Down
80 changes: 77 additions & 3 deletions packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mockLogger, mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { LDAP_FEATURE_NAME, type LdapConfig } from '@n8n/constants';
import type { Settings } from '@n8n/db';
import type { Settings, User } from '@n8n/db';
import { AuthIdentityRepository, SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { QueryFailedError } from '@n8n/typeorm';
Expand All @@ -23,6 +23,10 @@ import {
mapLdapUserToDbUser,
saveLdapSynchronization,
resolveEntryBinaryAttributes,
getAuthIdentityByLdapId,
getUserByEmail,
isLdapEnabled,
createLdapUserOnLocalDb,
} from '../helpers.ee';
import { LdapService } from '../ldap.service.ee';

Expand All @@ -45,6 +49,10 @@ jest.mock('../helpers.ee', () => ({
resolveBinaryAttributes: jest.fn(),
processUsers: jest.fn(),
resolveEntryBinaryAttributes: jest.fn(),
isLdapEnabled: jest.fn(() => true),
getAuthIdentityByLdapId: jest.fn(),
getUserByEmail: jest.fn(),
createLdapUserOnLocalDb: jest.fn(),
}));

jest.mock('n8n-workflow', () => ({
Expand Down Expand Up @@ -82,6 +90,7 @@ describe('LdapService', () => {
synchronizationInterval: 60,
searchPageSize: 1,
searchTimeout: 6,
enforceEmailUniqueness: true,
};

const settingsRepository = mockInstance(SettingsRepository);
Expand All @@ -102,10 +111,10 @@ describe('LdapService', () => {
} as Settings);
};

const createDefaultLdapService = (config: LdapConfig) => {
const createDefaultLdapService = (config: LdapConfig, eventService?: EventService) => {
mockSettingsRespositoryFindOneByOrFail(config);

return new LdapService(mockLogger(), settingsRepository, mock(), mock());
return new LdapService(mockLogger(), settingsRepository, mock(), eventService ?? mock());
};

describe('init()', () => {
Expand Down Expand Up @@ -1372,4 +1381,69 @@ describe('LdapService', () => {
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
});
});

describe('handleLdapLogin()', () => {
describe('enforceEmailUniqueness', () => {
const mockUser: User = mock<User>({
email: '[email protected]',
firstName: 'John',
lastName: 'Doe',
});

beforeEach(() => {
Client.prototype.search = jest.fn().mockImplementation(async () => ({
searchEntries: [
{
dn: 'uid:duplicate,ou=users,dc=example,dc=com',
cn: 'Duplicate',
mail: '[email protected]', // same email as jdoe
uid: 'duplicate',
},
{
dn: 'uid=jdoe,ou=users,dc=example,dc=com',
cn: 'John Doe',
mail: '[email protected]',
uid: 'jdoe',
},
],
}));

const mockedGetAuthIdentity = getAuthIdentityByLdapId as jest.Mock;
mockedGetAuthIdentity.mockResolvedValue(null);

const mockIsLdapEnabled = isLdapEnabled as jest.Mock;
mockIsLdapEnabled.mockReturnValue(true);

const mockedCreateLdapUserOnLocalDb = createLdapUserOnLocalDb as jest.Mock;
mockedCreateLdapUserOnLocalDb.mockResolvedValue(mockUser);

const mockedGetUserByEmail = getUserByEmail as jest.Mock;
mockedGetUserByEmail.mockResolvedValue(null);
});

it('should allow login when duplicates exist but enforcement is disabled', async () => {
const ldapService = createDefaultLdapService({
...ldapConfig,
enforceEmailUniqueness: false,
});

await ldapService.init();

const result = await ldapService.handleLdapLogin('jdoe', 'password');
expect(result).toEqual(mockUser);
});

it('should not allow login when duplicates exist and enforcement is enabled', async () => {
const ldapService = createDefaultLdapService({
...ldapConfig,
enforceEmailUniqueness: true,
});

await ldapService.init();

const result = await ldapService.handleLdapLogin('jdoe', 'password');
expect(result).toBeUndefined();
});
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/ldap.ee/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export const LDAP_CONFIG_SCHEMA = {
searchTimeout: {
type: 'number',
},
enforceEmailUniqueness: {
type: 'boolean',
},
},
required: [
'loginEnabled',
Expand Down Expand Up @@ -104,4 +107,5 @@ export const NON_SENSIBLE_LDAP_CONFIG_PROPERTIES: Array<keyof LdapConfig> = [
'searchPageSize',
'searchTimeout',
'loginLabel',
'enforceEmailUniqueness',
];
46 changes: 46 additions & 0 deletions packages/cli/src/ldap.ee/ldap.service.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ export class LdapService {
key: LDAP_FEATURE_NAME,
});
const ldapConfig = jsonParse<LdapConfig>(value);

// Apply secure default for new security field on existing instances
if (ldapConfig.enforceEmailUniqueness === undefined) {
ldapConfig.enforceEmailUniqueness = true;
}

ldapConfig.bindingAdminPassword = this.cipher.decrypt(ldapConfig.bindingAdminPassword);
return ldapConfig;
}
Expand Down Expand Up @@ -228,6 +234,33 @@ export class LdapService {
return [];
}

/**
* Check if multiple LDAP accounts exist with the same email address.
* Returns true if duplicates found, false otherwise.
* This prevents privilege escalation attacks via email-based account linking.
*/
private async hasEmailDuplicatesInLdap(email: string): Promise<boolean> {
try {
const searchResults = await this.searchWithAdminBinding(
createFilter(
`(${this.config.emailAttribute}=${escapeFilter(email)})`,
this.config.userFilter,
),
);

// If more than one LDAP entry has this email, it's a duplicate
return searchResults.length > 1;
} catch (error) {
// Log error but don't block login if search fails
this.logger.error('LDAP - Error checking for duplicate emails', {
email,
error: error instanceof Error ? error.message : 'Unknown error',
});
// Fail closed: treat search errors as potential duplicates for security
return true;
}
}

/**
* Attempt binding with the user's credentials
*/
Expand Down Expand Up @@ -482,6 +515,19 @@ export class LdapService {

const ldapAuthIdentity = await getAuthIdentityByLdapId(ldapId);
if (!ldapAuthIdentity) {
if (this.config.enforceEmailUniqueness) {
const hasDuplicates = await this.hasEmailDuplicatesInLdap(emailAttributeValue);

if (hasDuplicates) {
this.logger.warn('LDAP login blocked: Multiple LDAP accounts share the same email', {
email: emailAttributeValue,
ldapId,
});

return undefined;
}
}

const emailUser = await getUserByEmail(emailAttributeValue);

// check if there is an email user with the same email as the authenticated LDAP user trying to log-in
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/@n8n/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,8 @@
"settings.ldap.form.pageSize.infoText": "Max number of records to return per page during synchronization. 0 for unlimited",
"settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)",
"settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server",
"settings.ldap.form.enforceEmailUniqueness.label": "Enforce Email Uniqueness",
"settings.ldap.form.enforceEmailUniqueness.tooltip": "Prevents login if multiple LDAP accounts use the same email, blocking account linking attacks.",
"settings.ldap.section.synchronization.title": "Synchronization",
"settings.sso": "SSO",
"settings.sso.title": "Single Sign On",
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/@n8n/rest-api-client/src/api/ldap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface LdapConfig {
synchronizationInterval: number; // minutes
searchPageSize: number;
searchTimeout: number;
enforceEmailUniqueness: boolean;
}

export async function getLdapConfig(context: IRestApiContext): Promise<LdapConfig> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type LDAPConfigForm = {
synchronizationInterval: string;
pageSize: string;
searchTimeout: string;
enforceEmailUniqueness: boolean;
};

type CellClassStyleMethodParams<T> = {
Expand Down Expand Up @@ -167,6 +168,7 @@ const onSubmit = async () => {
synchronizationInterval: +formValues.synchronizationInterval,
searchPageSize: +formValues.pageSize,
searchTimeout: +formValues.searchTimeout,
enforceEmailUniqueness: formValues.enforceEmailUniqueness,
};

let saveForm = true;
Expand Down Expand Up @@ -548,6 +550,17 @@ const getLdapConfig = async () => {
},
shouldDisplay: whenSyncAndLoginEnabled,
},
{
name: 'enforceEmailUniqueness',
initialValue: adConfig.value.enforceEmailUniqueness,
properties: {
type: 'toggle',
label: i18n.baseText('settings.ldap.form.enforceEmailUniqueness.label'),
tooltipText: i18n.baseText('settings.ldap.form.enforceEmailUniqueness.tooltip'),
required: false,
},
shouldDisplay: whenLoginEnabled,
},
];
} catch (error) {
toast.showError(error, i18n.baseText('settings.ldap.configurationError'));
Expand Down
Loading