Skip to content

Commit 9e5212e

Browse files
n8n-assistant[bot]tomiyehorkardashgeemanjsivov
authored andcommitted
chore: Bundle 2026-W7 (#26214)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Co-authored-by: yehorkardash <yehor.kardash@n8n.io> Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Stephen Wright <sjw948@gmail.com> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Albert Alises <albert.alises@gmail.com> Co-authored-by: Danny Martini <danny@n8n.io>
1 parent 8e81f3e commit 9e5212e

37 files changed

+2484
-294
lines changed

packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/GenericFunctions.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,27 @@ export async function validateAuth(context: IWebhookFunctions) {
3737
} else if (authentication === 'n8nUserAuth') {
3838
const webhookName = context.getWebhookName();
3939

40-
function getCookie(name: string) {
41-
const value = `; ${headers.cookie}`;
42-
const parts = value.split(`; ${name}=`);
40+
if (webhookName !== 'setup') {
41+
function getCookie(name: string) {
42+
const value = `; ${headers.cookie}`;
43+
const parts = value.split(`; ${name}=`);
4344

44-
if (parts.length === 2) {
45-
return parts.pop()?.split(';').shift();
45+
if (parts.length === 2) {
46+
return parts.pop()?.split(';').shift();
47+
}
48+
return '';
4649
}
47-
return '';
48-
}
4950

50-
const authCookie = getCookie('n8n-auth');
51-
if (!authCookie && webhookName !== 'setup') {
52-
// Data is not defined on node so can not authenticate
53-
throw new ChatTriggerAuthorizationError(500, 'User not authenticated!');
51+
const authCookie = getCookie('n8n-auth');
52+
if (!authCookie) {
53+
throw new ChatTriggerAuthorizationError(401, 'User not authenticated!');
54+
}
55+
56+
try {
57+
await context.validateCookieAuth(authCookie);
58+
} catch {
59+
throw new ChatTriggerAuthorizationError(401, 'Invalid authentication token');
60+
}
5461
}
5562
}
5663

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { mock } from 'jest-mock-extended';
2+
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
3+
4+
import { ChatTriggerAuthorizationError } from '../error';
5+
import { validateAuth } from '../GenericFunctions';
6+
7+
describe('validateAuth', () => {
8+
const mockContext = mock<IWebhookFunctions>();
9+
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
describe('authentication = none', () => {
15+
it('should pass without error', async () => {
16+
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('none');
17+
18+
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
19+
});
20+
});
21+
22+
describe('authentication = basicAuth', () => {
23+
beforeEach(() => {
24+
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('basicAuth');
25+
});
26+
27+
it('should throw 500 when credentials are not defined', async () => {
28+
mockContext.getCredentials.mockRejectedValue(new Error('No credentials'));
29+
30+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
31+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
32+
responseCode: 500,
33+
});
34+
});
35+
36+
it('should throw 401 when no auth header is provided', async () => {
37+
mockContext.getCredentials.mockResolvedValue({
38+
user: 'admin',
39+
password: 'secret',
40+
} as ICredentialDataDecryptedObject);
41+
mockContext.getRequestObject.mockReturnValue({
42+
headers: {},
43+
} as never);
44+
45+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
46+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
47+
responseCode: 401,
48+
});
49+
});
50+
51+
it('should throw 403 when credentials are wrong', async () => {
52+
mockContext.getCredentials.mockResolvedValue({
53+
user: 'admin',
54+
password: 'secret',
55+
} as ICredentialDataDecryptedObject);
56+
mockContext.getRequestObject.mockReturnValue({
57+
headers: {
58+
authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64'),
59+
},
60+
} as never);
61+
62+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
63+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
64+
responseCode: 403,
65+
});
66+
});
67+
68+
it('should pass with correct credentials', async () => {
69+
mockContext.getCredentials.mockResolvedValue({
70+
user: 'admin',
71+
password: 'secret',
72+
} as ICredentialDataDecryptedObject);
73+
mockContext.getRequestObject.mockReturnValue({
74+
headers: {
75+
authorization: 'Basic ' + Buffer.from('admin:secret').toString('base64'),
76+
},
77+
} as never);
78+
79+
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
80+
});
81+
});
82+
83+
describe('authentication = n8nUserAuth', () => {
84+
beforeEach(() => {
85+
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('n8nUserAuth');
86+
});
87+
88+
it('should skip validation for setup webhook', async () => {
89+
mockContext.getWebhookName.mockReturnValue('setup');
90+
mockContext.getHeaderData.mockReturnValue({});
91+
92+
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
93+
});
94+
95+
it('should throw 401 when no n8n-auth cookie is present', async () => {
96+
mockContext.getWebhookName.mockReturnValue('default');
97+
mockContext.getHeaderData.mockReturnValue({});
98+
99+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
100+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
101+
responseCode: 401,
102+
message: 'User not authenticated!',
103+
});
104+
});
105+
106+
it('should throw 401 when cookie has a fake/invalid token', async () => {
107+
mockContext.getWebhookName.mockReturnValue('default');
108+
mockContext.getHeaderData.mockReturnValue({
109+
cookie: 'n8n-auth=anything',
110+
});
111+
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
112+
113+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
114+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
115+
responseCode: 401,
116+
message: 'Invalid authentication token',
117+
});
118+
});
119+
120+
it('should throw 401 when validateCookieAuth rejects (revoked token)', async () => {
121+
mockContext.getWebhookName.mockReturnValue('default');
122+
mockContext.getHeaderData.mockReturnValue({
123+
cookie: 'n8n-auth=some.revoked.token',
124+
});
125+
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
126+
127+
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
128+
await expect(validateAuth(mockContext)).rejects.toMatchObject({
129+
responseCode: 401,
130+
message: 'Invalid authentication token',
131+
});
132+
});
133+
134+
it('should pass with a valid token', async () => {
135+
mockContext.getWebhookName.mockReturnValue('default');
136+
mockContext.getHeaderData.mockReturnValue({
137+
cookie: 'n8n-auth=valid.jwt.token',
138+
});
139+
mockContext.validateCookieAuth.mockResolvedValue(undefined);
140+
141+
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
142+
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
143+
});
144+
145+
it('should pass when cookie has other cookies alongside n8n-auth', async () => {
146+
mockContext.getWebhookName.mockReturnValue('default');
147+
mockContext.getHeaderData.mockReturnValue({
148+
cookie: 'other=value; n8n-auth=valid.jwt.token; another=thing',
149+
});
150+
mockContext.validateCookieAuth.mockResolvedValue(undefined);
151+
152+
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
153+
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
154+
});
155+
});
156+
});

packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('ChatTrigger Templates Security', () => {
1515
allowedFilesMimeTypes: '',
1616
customCss: '',
1717
enableStreaming: false,
18+
initialMessages: '',
1819
};
1920

2021
describe('XSS Prevention in initialMessages', () => {
@@ -213,6 +214,54 @@ describe('ChatTrigger Templates Security', () => {
213214
});
214215
});
215216

217+
describe('XSS Prevention in allowedFilesMimeTypes', () => {
218+
it('should prevent script injection through allowedFilesMimeTypes', () => {
219+
const maliciousInput = '</script><script>alert(document.cookie)</script>';
220+
221+
const result = createPage({
222+
...defaultParams,
223+
allowFileUploads: true,
224+
allowedFilesMimeTypes: maliciousInput,
225+
});
226+
227+
expect(result).not.toContain('<script>alert(document.cookie)</script>');
228+
expect(result).not.toContain('</script><script>');
229+
expect(result).not.toContain('alert(document.cookie)');
230+
});
231+
232+
it('should sanitize common XSS payloads in allowedFilesMimeTypes', () => {
233+
const xssPayloads = [
234+
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
235+
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
236+
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
237+
];
238+
239+
xssPayloads.forEach(({ input, dangerous }) => {
240+
const result = createPage({
241+
...defaultParams,
242+
allowFileUploads: true,
243+
allowedFilesMimeTypes: input,
244+
});
245+
246+
dangerous.forEach((dangerousContent) => {
247+
expect(result).not.toContain(dangerousContent);
248+
});
249+
});
250+
});
251+
252+
it('should preserve legitimate MIME types', () => {
253+
const legitimateMimeTypes = 'image/*,text/plain,application/pdf';
254+
255+
const result = createPage({
256+
...defaultParams,
257+
allowFileUploads: true,
258+
allowedFilesMimeTypes: legitimateMimeTypes,
259+
});
260+
261+
expect(result).toContain(legitimateMimeTypes);
262+
});
263+
});
264+
216265
describe('getSanitizedInitialMessages function', () => {
217266
it('should sanitize XSS payloads', () => {
218267
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';

packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function createPage({
7777
: 'none';
7878
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
7979
const sanitizedAllowFileUploads = !!allowFileUploads;
80-
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
80+
const sanitizedAllowedFilesMimeTypes = sanitizeUserInput(allowedFilesMimeTypes?.toString() ?? '');
8181
const sanitizedCustomCss = sanitizeHtml(`<style>${customCss?.toString() ?? ''}</style>`, {
8282
allowedTags: ['style'],
8383
allowedAttributes: false,

packages/@n8n/task-runner-python/src/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"obj",
150150
"__thisclass__",
151151
"__self_class__",
152+
"__objclass__",
152153
# introspection attributes
153154
"__base__",
154155
"__class__",

packages/@n8n/task-runner-python/tests/unit/test_task_analyzer.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,19 @@ def test_name_mangled_attributes_blocked(self, analyzer: TaskAnalyzer) -> None:
150150
analyzer.validate(code)
151151
assert "name-mangled" in exc_info.value.description.lower()
152152

153+
def test_objclass_attribute_blocked(self, analyzer: TaskAnalyzer) -> None:
154+
exploit_attempts = [
155+
"str.__or__.__objclass__",
156+
"str.__init__.__objclass__",
157+
"type_ref = str.__or__.__objclass__",
158+
"object_ref = str.__init__.__objclass__",
159+
]
160+
161+
for code in exploit_attempts:
162+
with pytest.raises(SecurityViolationError) as exc_info:
163+
analyzer.validate(code)
164+
assert "__objclass__" in exc_info.value.description
165+
153166
def test_attribute_error_obj_blocked(self, analyzer: TaskAnalyzer) -> None:
154167
exploit_attempts = [
155168
"e.obj",

0 commit comments

Comments
 (0)