fix: Update packages for security fixes (#21375)

This commit is contained in:
Declan Carroll
2025-10-30 17:00:18 +00:00
committed by GitHub
parent eb629887dc
commit c553c4d566
16 changed files with 1427 additions and 95 deletions
+3 -1
View File
@@ -111,7 +111,9 @@
"date-fns": "2.30.0",
"date-fns-tz": "2.0.0",
"form-data": "4.0.4",
"tmp": "0.2.4"
"tmp": "0.2.4",
"nodemailer": "7.0.10",
"validator": "13.15.20"
},
"patchedDependencies": {
"bull@4.16.4": "patches/bull@4.16.4.patch",
+2 -2
View File
@@ -153,7 +153,7 @@
"n8n-nodes-base": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"nodemailer": "6.9.9",
"nodemailer": "catalog:",
"oauth-1.0a": "2.2.6",
"open": "7.4.2",
"openid-client": "6.5.0",
@@ -180,7 +180,7 @@
"syslog-client": "1.1.1",
"undici": "^7.16.0",
"uuid": "catalog:",
"validator": "13.7.0",
"validator": "13.15.20",
"ws": "8.17.1",
"xml2js": "catalog:",
"xmllint-wasm": "3.0.1",
@@ -245,7 +245,6 @@ export async function encodeEmail(email: IEmail) {
// by default the bcc headers are deleted when the mail is built.
// So add keepBcc flag to override such behaviour. Only works when
// the flag is set after the compilation.
// @ts-expect-error - https://nodemailer.com/extras/mailcomposer/#bcc
mail.keepBcc = true;
const mailBody = await mail.build();
@@ -5,6 +5,33 @@ import nock from 'nock';
import labels from '../fixtures/labels.json';
import messages from '../fixtures/messages.json';
function normalizeDraftMail(mail: string) {
let normalizedMail = mail.replace(/\r\n/g, '\n');
normalizedMail = normalizedMail
.replace(/boundary=\".*\"/g, 'boundary="--test-boundary"')
.replace(/----.*/g, '----test-boundary')
.replace(/^From:.*$/gm, '')
.replace(/Message-ID:.*/g, 'Message-ID: test-message-id');
const parts = normalizedMail.split(/\n\n/);
if (parts.length > 1) {
const headerBlock = parts[0];
const bodyBlock = parts.slice(1).join('\n\n');
const headers = headerBlock.split(/\n/).filter(Boolean);
const map = new Map<string, string>();
headers.forEach((line) => {
const idx = line.indexOf(':');
if (idx > -1) map.set(line.slice(0, idx), line);
});
const ordered = ['Content-Type', 'Cc', 'Bcc', 'Subject', 'Message-ID', 'Date', 'MIME-Version']
.map((k) => map.get(k))
.filter(Boolean) as string[];
normalizedMail = `${ordered.join('\n')}\n\n${bodyBlock}`;
}
return normalizedMail;
}
describe('Test Gmail Node v1', () => {
beforeAll(() => {
jest
@@ -128,24 +155,49 @@ describe('Test Gmail Node v1', () => {
const parsedBody = jsonParse<{ message: { raw: string; threadId: string } }>(body);
const mail = Buffer.from(parsedBody.message.raw, 'base64').toString('utf-8');
// Remove dynamic fields from mail
parsedBody.message.raw = Buffer.from(
mail
.replace(/boundary=".*"/g, 'boundary="--test-boundary"')
.replace(/----.*/g, '----test-boundary')
.replace(/Message-ID:.*/g, 'Message-ID: test-message-id'),
'utf-8',
).toString('base64');
const normalizedMail = normalizeDraftMail(mail);
parsedBody.message.raw = Buffer.from(normalizedMail, 'utf-8').toString('base64');
return JSON.stringify(parsedBody);
} catch (error) {
return body;
}
})
.post('/v1/users/me/drafts', {
message: {
raw: 'Q29udGVudC1UeXBlOiBtdWx0aXBhcnQvbWl4ZWQ7IGJvdW5kYXJ5PSItLXRlc3QtYm91bmRhcnkiDQpDYzogdGVzdF9jY0BuOG4uaW8NCkJjYzogdGVzdF9iY2NAbjhuLmlvDQpTdWJqZWN0OiBUZXN0IFN1YmplY3QNCk1lc3NhZ2UtSUQ6IHRlc3QtbWVzc2FnZS1pZA0KRGF0ZTogTW9uLCAxNiBEZWMgMjAyNCAxMjozNDo1NiArMDAwMA0KTUlNRS1WZXJzaW9uOiAxLjANCg0KLS0tLXRlc3QtYm91bmRhcnkNCkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsgY2hhcnNldD11dGYtOA0KQ29udGVudC1UcmFuc2Zlci1FbmNvZGluZzogN2JpdA0KDQpUZXN0IE1lc3NhZ2UNCi0tLS10ZXN0LWJvdW5kYXJ5DQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb247IG5hbWU9ZmlsZS5qc29uDQpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQNCkNvbnRlbnQtRGlzcG9zaXRpb246IGF0dGFjaG1lbnQ7IGZpbGVuYW1lPWZpbGUuanNvbg0KDQpXM3NpWVhSMFlXTm9iV1Z1ZENJNmRISjFaWDFkDQotLS0tdGVzdC1ib3VuZGFyeQ0K',
},
.post('/v1/users/me/drafts', (reqBody) => {
try {
const b = typeof reqBody === 'string' ? JSON.parse(reqBody) : reqBody;
const raw = b?.message?.raw as string;
if (typeof raw !== 'string') return false;
const mail = Buffer.from(raw, 'base64').toString('utf-8');
const normalized = normalizeDraftMail(mail);
const expectedNormalized = [
'Content-Type: multipart/mixed; boundary="--test-boundary"',
'Cc: test_cc@n8n.io',
'Bcc: test_bcc@n8n.io',
'Subject: Test Subject',
'Message-ID: test-message-id',
'Date: Mon, 16 Dec 2024 12:34:56 +0000',
'MIME-Version: 1.0',
'',
'----test-boundary',
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: 7bit',
'',
'Test Message',
'----test-boundary',
'Content-Type: application/json; name=file.json',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename=file.json',
'',
'W3siYXR0YWNobWVudCI6dHJ1ZX1d',
'----test-boundary',
].join('\n');
// eslint-disable-next-line no-console
console.log('Normalized (v1) actual:', normalized);
return normalized.trimEnd() === expectedNormalized.trimEnd();
} catch {
return false;
}
})
.query({ userId: 'me', uploadType: 'media' })
.reply(200, messages[0]);
@@ -642,6 +642,7 @@ export class GmailV1 implements INodeType {
}
const email: IEmail = {
from: (additionalFields.senderName as string) || '',
to: toStr,
cc: ccStr,
bcc: bccStr,
+2 -2
View File
@@ -871,7 +871,7 @@
"@types/mailparser": "^3.4.4",
"@types/mime-types": "^2.1.0",
"@types/mssql": "^9.1.5",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^7.0.3",
"@types/oracledb": "^6.9.1",
"@types/promise-ftp": "^1.3.4",
"@types/rfc2047": "^2.0.1",
@@ -932,7 +932,7 @@
"n8n-workflow": "workspace:*",
"node-html-markdown": "1.2.0",
"node-ssh": "13.2.0",
"nodemailer": "6.9.9",
"nodemailer": "catalog:",
"oracledb": "6.9.0",
"otpauth": "9.1.1",
"pdfjs-dist": "5.3.31",
+10
View File
@@ -10,3 +10,13 @@ export type { N8NConfig, N8NStack } from './n8n-test-container-creation';
export * from './performance-plans';
export { ContainerTestHelpers } from './n8n-test-container-helpers';
export {
setupMailpit,
getMailpitEnvironment,
mailpitClear,
mailpitList,
mailpitGet,
mailpitWaitForMessage,
type MailpitMessage,
type MailpitQuery,
} from './n8n-test-container-mailpit';
@@ -28,6 +28,7 @@ import {
setupTaskRunner,
} from './n8n-test-container-dependencies';
import { setupGitea } from './n8n-test-container-gitea';
import { setupMailpit, getMailpitEnvironment } from './n8n-test-container-mailpit';
import { createSilentLogConsumer } from './n8n-test-container-utils';
// --- Constants ---
@@ -87,6 +88,7 @@ export interface N8NConfig {
proxyServerEnabled?: boolean;
sourceControl?: boolean;
taskRunner?: boolean;
email?: boolean;
}
export interface N8NStack {
@@ -127,10 +129,12 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
resourceQuota,
taskRunner = false,
sourceControl = false,
email = false,
} = config;
const queueConfig = normalizeQueueConfig(queueMode);
const taskRunnerEnabled = !!taskRunner;
const sourceControlEnabled = !!sourceControl;
const emailEnabled = !!email;
const usePostgres = postgres || !!queueConfig;
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
const containers: StartedTestContainer[] = [];
@@ -143,7 +147,8 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
needsLoadBalancer ||
proxyServerEnabled ||
taskRunnerEnabled ||
sourceControlEnabled;
sourceControlEnabled ||
emailEnabled;
let network: StartedNetwork | undefined;
if (needsNetwork) {
@@ -243,6 +248,27 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
};
}
// Set up Mailpit BEFORE creating n8n instances so they have the correct environment
if (emailEnabled && network) {
const hostname = 'mailpit';
const smtpPort = 1025;
const httpPort = 8025;
const mailpitContainer = await setupMailpit({
projectName: uniqueProjectName,
network,
hostname,
smtpPort,
httpPort,
});
containers.push(mailpitContainer);
environment = {
...environment,
...getMailpitEnvironment(hostname, smtpPort),
};
}
let baseUrl: string;
if (needsLoadBalancer) {
@@ -1,6 +1,17 @@
import { setTimeout as wait } from 'node:timers/promises';
import type { StartedTestContainer, StoppedTestContainer } from 'testcontainers';
import {
getMailpitApiBaseUrl,
mailpitWaitForMessage,
mailpitList,
mailpitClear,
mailpitGet,
type MailpitQuery,
type MailpitMessage,
type MailpitMessageSummary,
} from './n8n-test-container-mailpit';
export interface LogMatch {
container: StartedTestContainer;
containerName: string;
@@ -30,10 +41,20 @@ export class ContainerTestHelpers {
// Containers
private containers: StartedTestContainer[];
private _mailHelper?: MailHelper;
constructor(containers: StartedTestContainer[]) {
this.containers = containers;
}
/**
* Mail helper facade for Mailpit interactions
*/
get mail(): MailHelper {
this._mailHelper ??= new MailHelper(this.containers);
return this._mailHelper;
}
/**
* Read logs from a container
*/
@@ -374,3 +395,37 @@ export class ContainerTestHelpers {
return matches;
}
}
class MailHelper {
constructor(private containers: StartedTestContainer[]) {}
private getMailpitContainer(): StartedTestContainer {
const container = this.containers.find((c) => /mailpit/i.test(c.getName()));
if (!container) throw new Error('Mailpit container not found');
return container;
}
private get apiBaseUrl(): string {
const mailpit = this.getMailpitContainer();
return getMailpitApiBaseUrl(mailpit);
}
async waitForMessage(
query: MailpitQuery,
options?: { timeoutMs?: number; pollMs?: number },
): Promise<MailpitMessageSummary> {
return await mailpitWaitForMessage(this.apiBaseUrl, query, options);
}
async list(): Promise<MailpitMessageSummary[]> {
return await mailpitList(this.apiBaseUrl);
}
async clear(): Promise<void> {
await mailpitClear(this.apiBaseUrl);
}
async get(id: string): Promise<MailpitMessage> {
return await mailpitGet(this.apiBaseUrl, id);
}
}
@@ -0,0 +1,198 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from './n8n-test-container-utils';
type MailpitAddress = {
Address: string;
Name?: string;
};
// Message summary as returned in list responses
export type MailpitMessageSummary = {
ID: string;
MessageID: string;
Read: boolean;
From: MailpitAddress;
To: MailpitAddress[];
Cc: MailpitAddress[] | null;
Bcc: MailpitAddress[] | null;
ReplyTo: MailpitAddress[];
Subject: string;
Created: string;
Username: string;
Tags: string[];
Size: number;
Attachments: number;
Snippet: string;
};
// Full message as returned by GET /api/v1/message/{id}
export type MailpitMessage = MailpitMessageSummary & {
Text?: string;
HTML?: string;
Inline?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
Attachments?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
};
export type MailpitQuery = {
to?: string | RegExp;
subject?: string | RegExp;
};
export type MailpitListResponse = {
total: number;
unread: number;
count: number; // Deprecated but kept for backwards compatibility
messages_count: number;
messages_unread: number;
start: number;
tags: string[];
messages: MailpitMessageSummary[];
};
export function getMailpitEnvironment(
hostname = 'mailpit',
smtpPort = 1025,
): Record<string, string> {
return {
N8N_EMAIL_MODE: 'smtp',
N8N_SMTP_HOST: hostname,
N8N_SMTP_PORT: String(smtpPort),
N8N_SMTP_SSL: 'false',
N8N_SMTP_SENDER: 'test@n8n.local',
};
}
export async function setupMailpit({
projectName,
network,
hostname = 'mailpit',
smtpPort = 1025,
httpPort = 8025,
}: {
projectName: string;
network: StartedNetwork;
hostname?: string;
smtpPort?: number;
httpPort?: number;
}): Promise<StartedTestContainer> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
return await new GenericContainer('axllent/mailpit:latest')
.withNetwork(network)
.withNetworkAliases(hostname)
.withExposedPorts(smtpPort, httpPort)
.withEnvironment({
MP_UI_BIND_ADDR: `0.0.0.0:${httpPort}`,
MP_SMTP_BIND_ADDR: `0.0.0.0:${smtpPort}`,
})
.withWaitStrategy(
Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/api/v1/info', httpPort).forStatusCode(200).withStartupTimeout(30000),
]),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'mailpit',
})
.withName(`${projectName}-mailpit`)
.withReuse()
.withLogConsumer(consumer)
.start();
} catch (error) {
return throwWithLogs(error);
}
}
export function getMailpitApiBaseUrl(container: StartedTestContainer): string {
return `http://${container.getHost()}:${container.getMappedPort(8025)}`;
}
export async function mailpitClear(apiBaseUrl: string): Promise<void> {
const res = await fetch(`${apiBaseUrl}/api/v1/messages`, { method: 'DELETE' });
if (!res.ok) {
throw new Error(`Mailpit clear failed: ${res.status} ${res.statusText}`);
}
}
export async function mailpitList(apiBaseUrl: string): Promise<MailpitMessageSummary[]> {
const res = await fetch(`${apiBaseUrl}/api/v1/messages`);
if (!res.ok) {
throw new Error(`Mailpit list failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as MailpitListResponse;
return data.messages || [];
}
export async function mailpitGet(apiBaseUrl: string, id: string): Promise<MailpitMessage> {
const res = await fetch(`${apiBaseUrl}/api/v1/message/${id}`);
if (!res.ok) {
throw new Error(`Mailpit get failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as MailpitMessage;
return data;
}
export async function mailpitWaitForMessage(
apiBaseUrl: string,
query: MailpitQuery,
options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<MailpitMessageSummary> {
const { timeoutMs = 10000, pollMs = 200 } = options;
const deadline = Date.now() + timeoutMs;
const messageMatches = (message: MailpitMessageSummary): boolean => {
if (query.to) {
const hasMatchingRecipient = message.To.some((recipient) =>
typeof query.to === 'string'
? recipient.Address === query.to
: query.to!.test(recipient.Address),
);
if (!hasMatchingRecipient) return false;
}
if (query.subject) {
const subjectMatches =
typeof query.subject === 'string'
? message.Subject === query.subject
: query.subject.test(message.Subject);
if (!subjectMatches) return false;
}
return true;
};
while (Date.now() < deadline) {
const messages = await mailpitList(apiBaseUrl);
const match = messages.find(messageMatches);
if (match) {
return match;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
const queryParts = [];
if (query.to) queryParts.push(`to: ${query.to}`);
if (query.subject) queryParts.push(`subject: ${query.subject}`);
throw new Error(`Mail not received within ${timeoutMs}ms. Query: ${queryParts.join(', ')}`);
}
+6 -6
View File
@@ -39,6 +39,7 @@ interface ContainerConfig {
proxyServerEnabled?: boolean;
taskRunner?: boolean;
sourceControl?: boolean;
email?: boolean;
}
/**
@@ -65,12 +66,11 @@ export const test = base.extend<
// Container configuration from the project use options
containerConfig: [
async ({ addContainerCapability }, use, workerInfo) => {
const baseConfig =
(workerInfo.project.use as unknown as { containerConfig?: ContainerConfig })
?.containerConfig ?? {};
const projectConfig = workerInfo.project.use as { containerConfig?: ContainerConfig };
const baseConfig = projectConfig?.containerConfig ?? {};
// Merge addContainerCapability with base config
const config: ContainerConfig = {
// Build merged configuration
const merged: ContainerConfig = {
...baseConfig,
...addContainerCapability,
env: {
@@ -80,7 +80,7 @@ export const test = base.extend<
},
};
await use(config);
await use(merged);
},
{ scope: 'worker', box: true },
],
@@ -10,6 +10,7 @@ const CONTAINER_ONLY_TAGS = [
'multi-main',
'task-runner',
'source-control',
'email',
];
const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')})`);
@@ -0,0 +1,79 @@
import { test, expect } from '../../fixtures/base';
test.use({ addContainerCapability: { email: true } });
test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, chaos }) => {
// Sign in to use internal APIs for creating credentials and workflows
// Create SMTP credential targeting Mailpit
const smtpCredential = await api.credentials.createCredential({
name: 'SMTP (Test)',
type: 'smtp',
data: {
user: '',
password: '',
host: 'mailpit',
port: 1025,
secure: false,
disableStartTls: true,
},
});
// Define a workflow with Manual Trigger -> EmailSend
const toEmail = 'test@recipient.local';
const subject = 'Playwright Mailpit SMTP';
const workflowDefinition = {
name: 'Mailpit EmailSend Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Email',
type: 'n8n-nodes-base.emailSend',
typeVersion: 2,
position: [300, 0],
parameters: {
fromEmail: 'test@n8n.local',
toEmail,
subject,
emailFormat: 'text',
text: 'Hello from n8n E2E test',
},
credentials: {
smtp: {
id: smtpCredential.id,
name: smtpCredential.name,
},
},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Email', type: 'main', index: 0 }]],
},
},
active: false,
} as const;
const { workflowId } = await api.workflows.createWorkflowFromDefinition(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflowDefinition as any,
{ makeUnique: true },
);
// Execute the workflow via UI API endpoint by navigating to the canvas and clicking run
await n8n.page.goto(`/workflow/${workflowId}`);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
const msg = await chaos.mail.waitForMessage({ to: toEmail, subject });
expect(msg).toBeTruthy();
});
@@ -0,0 +1,14 @@
import { test, expect } from '../../fixtures/base';
test.use({ addContainerCapability: { email: true } });
test('Password reset email is delivered @capability:email', async ({ api, chaos }) => {
const ownerEmail = 'nathan@n8n.io';
const res = await api.request.post('/rest/forgot-password', {
data: { email: ownerEmail },
});
expect(res.ok()).toBeTruthy();
const msg = await chaos.mail.waitForMessage({ to: ownerEmail, subject: /password reset/i });
expect(msg).toBeTruthy();
});
+964 -70
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -61,6 +61,7 @@ catalog:
eslint: 9.29.0
mysql2: 3.15.0
run-script-os: 1.1.6
nodemailer: 7.0.10
catalogs:
frontend: