chore(core): Enforce email format for user entity, remove unused user validators (#18534)

This commit is contained in:
Andreas Fitzek
2025-08-21 14:41:32 +02:00
committed by GitHub
parent 9ab150961d
commit 04e24e387d
14 changed files with 391 additions and 30 deletions

View File

@@ -382,7 +382,7 @@ describe('GET /resolve-signup-token', () => {
// cause inconsistent DB state
owner.email = '';
await Container.get(UserRepository).save(owner);
await Container.get(UserRepository).save(owner, { listeners: false });
const fifth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })

View File

@@ -435,6 +435,94 @@ describe('POST /ldap/sync', () => {
const response = await testServer.authAgentFor(member).get('/login');
expect(response.status).toBe(401);
});
test('should filter out users with invalid email addresses during sync', async () => {
const validLdapUser = {
mail: randomEmail(),
dn: '',
sn: randomName(),
givenName: randomName(),
uid: uniqueId(),
};
const invalidLdapUser = {
mail: 'invalid-email',
dn: '',
sn: randomName(),
givenName: randomName(),
uid: uniqueId(),
};
const ldapUsers = [validLdapUser, invalidLdapUser];
const loggerSpy = jest.spyOn(Container.get(LdapService)['logger'], 'warn');
const synchronization = await runTest(ldapUsers);
// Should only create 1 user (the valid one)
expect(synchronization.created).toBe(1);
// Should log error for invalid email
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining(`LDAP - Invalid email format for user ${invalidLdapUser.uid}`),
);
loggerSpy.mockReset();
loggerSpy.mockRestore();
// Verify only valid user was created
const allUsers = await getAllUsers();
expect(allUsers.length).toBe(2); // owner + valid user
const memberUser = allUsers.find((u) => u.email !== owner.email)!;
expect(memberUser.email).toBe(validLdapUser.mail);
});
test('should filter out users with invalid email addresses during update', async () => {
const originalEmail = randomEmail();
const originalUserId = uniqueId();
// Create user with valid email first
await createLdapUser(
{
role: 'global:member',
email: originalEmail,
firstName: randomName(),
lastName: randomName(),
},
originalUserId,
);
// Now try to update with invalid email
const invalidLdapUser = {
mail: 'not-an-email',
dn: '',
sn: randomName(),
givenName: randomName(),
uid: originalUserId,
};
const loggerSpy = jest.spyOn(Container.get(LdapService)['logger'], 'warn');
const synchronization = await runTest([invalidLdapUser]);
// Should not update any users
expect(synchronization.updated).toBe(0);
// Should log error for invalid email
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining(`LDAP - Invalid email format for user ${originalUserId}`),
);
loggerSpy.mockReset();
loggerSpy.mockRestore();
// Verify user still has original email
const localLdapIdentities = await getLdapIdentities();
const localLdapUsers = localLdapIdentities.map(({ user }) => user);
expect(localLdapUsers.length).toBe(1);
expect(localLdapUsers[0].email).toBe(originalEmail);
});
});
});

View File

@@ -353,6 +353,79 @@ describe('OIDC service', () => {
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
});
it('should throw `BadRequestError` if OIDC Idp provides an invalid email format', async () => {
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
mocked_oidc_client.TokenEndpointResponseHelpers = {
access_token: 'mock-access-token-invalid',
id_token: 'mock-id-token-invalid',
token_type: 'bearer',
claims: () => {
return {
sub: 'mock-subject-invalid',
iss: 'https://example.com/auth/realms/n8n',
aud: 'test-client-id',
iat: Math.floor(Date.now() / 1000) - 1000,
exp: Math.floor(Date.now() / 1000) + 3600,
} as mocked_oidc_client.IDToken;
},
expiresIn: () => 3600,
} as mocked_oidc_client.TokenEndpointResponse &
mocked_oidc_client.TokenEndpointResponseHelpers;
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
// Provide an invalid email format
fetchUserInfoMock.mockResolvedValueOnce({
email_verified: true,
email: 'invalid-email-format',
});
const error = await oidcService.loginUser(callbackUrl).catch((e) => e);
expect(error.message).toBe('Invalid email format');
});
it.each([
['not-an-email'],
['@missinglocal.com'],
['missing@.com'],
['spaces in@email.com'],
['double@@domain.com'],
])('should throw `BadRequestError` for invalid email <%s>', async (invalidEmail) => {
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
mocked_oidc_client.TokenEndpointResponseHelpers = {
access_token: 'mock-access-token-multi',
id_token: 'mock-id-token-multi',
token_type: 'bearer',
claims: () => {
return {
sub: 'mock-subject-multi',
iss: 'https://example.com/auth/realms/n8n',
aud: 'test-client-id',
iat: Math.floor(Date.now() / 1000) - 1000,
exp: Math.floor(Date.now() / 1000) + 3600,
} as mocked_oidc_client.IDToken;
},
expiresIn: () => 3600,
} as mocked_oidc_client.TokenEndpointResponse &
mocked_oidc_client.TokenEndpointResponseHelpers;
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
fetchUserInfoMock.mockResolvedValueOnce({
email_verified: true,
email: invalidEmail,
});
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
});
it('should throw `ForbiddenError` if OIDC token does not provide claims', async () => {
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',

View File

@@ -20,7 +20,7 @@ describe('sso/saml/samlHelpers', () => {
const samlUserAttributes: SamlUserAttributes = {
firstName: 'Nathan',
lastName: 'Nathaniel',
email: 'n@8.n',
email: 'nathan@n8n.io',
userPrincipalName: 'Huh?',
};

View File

@@ -2,8 +2,11 @@ import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import type express from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers';
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
import {
getCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
@@ -283,3 +286,86 @@ describe('Check endpoint permissions', () => {
});
});
});
describe('SAML email validation', () => {
let samlService: SamlService;
beforeAll(async () => {
samlService = Container.get(SamlService);
});
describe('handleSamlLogin', () => {
test('should throw BadRequestError for invalid email format', async () => {
// Mock getAttributesFromLoginResponse to return invalid email
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
email: 'invalid-email-format',
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
});
const mockRequest = {} as express.Request;
await expect(samlService.handleSamlLogin(mockRequest, 'post')).rejects.toThrow(
new BadRequestError('Invalid email format'),
);
});
test.each([['not-an-email'], ['@missinglocal.com'], ['missing@.com'], ['spaces in@email.com']])(
'should throw BadRequestError for invalid email <%s>',
async (invalidEmail) => {
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
email: invalidEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
});
const mockRequest = {} as express.Request;
await expect(samlService.handleSamlLogin(mockRequest, 'post')).rejects.toThrow(
new BadRequestError('Invalid email format'),
);
},
);
test.each([
['user@example.com'],
['test.email@domain.org'],
['user+tag@example.com'],
['user123@test-domain.com'],
])('should handle valid email <%s> successfully', async (validEmail) => {
const mockRequest = {} as express.Request;
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
email: validEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
});
// Should not throw an error for valid emails
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result).toBeDefined();
expect(result.attributes.email).toBe(validEmail);
});
test('should convert email to lowercase before validation', async () => {
const upperCaseEmail = 'USER@EXAMPLE.COM';
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
email: upperCaseEmail,
firstName: 'John',
lastName: 'Doe',
userPrincipalName: 'john.doe',
});
const mockRequest = {} as express.Request;
// Should not throw an error as the email is valid when converted to lowercase
const result = await samlService.handleSamlLogin(mockRequest, 'post');
expect(result).toBeDefined();
expect(result.attributes.email).toBe(upperCaseEmail); // Original email should be preserved in attributes
});
});
});