From 31fc941dd1084ed5e1d29e505f6f042cdbfd2832 Mon Sep 17 00:00:00 2001 From: TemoSulava Date: Fri, 16 Oct 2026 21:32:44 +0400 Subject: [PATCH 1/2] fix(Jenkins Node): Support CSRF crumb and queue redirect responses Fixes #40761 --- .../nodes/Jenkins/GenericFunctions.ts | 84 +++- .../Jenkins/test/GenericFunctions.test.ts | 386 +++++++++++++++++- .../nodes/Jenkins/test/Jenkins.node.test.ts | 63 ++- 3 files changed, 525 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts index e4b00b1303c..b13bb64994e 100644 --- a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts @@ -11,34 +11,106 @@ import { NodeApiError } from 'n8n-workflow'; import { removeTrailingSlash } from '@utils/utilities'; +export async function getJenkinsCrumb( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + credentials: IDataObject, +): Promise { + const baseUrl = (credentials.baseUrl as string) || ''; + const options: IRequestOptions = { + headers: { + Accept: 'application/json', + }, + method: 'GET', + auth: { + username: credentials.username as string, + password: credentials.apiKey as string, + }, + uri: `${removeTrailingSlash(baseUrl)}/crumbIssuer/api/json`, + json: true, + resolveWithFullResponse: true, + }; + try { + const response = (await this.helpers.request(options)) as { + body?: IDataObject; + headers?: Record; + }; + const body = (response?.body ?? response) as IDataObject; + if (body && body.crumbRequestField && body.crumb) { + const crumbHeaders: IDataObject = { + [body.crumbRequestField as string]: body.crumb as string, + }; + const setCookie = response.headers?.['set-cookie']; + if (setCookie) { + crumbHeaders.Cookie = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + } + return crumbHeaders; + } + } catch { + // Crumb issuer is not enabled (e.g. 404) or unavailable; proceed without it + } + return null; +} + export async function jenkinsApiRequest( this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, uri: string, qs: IDataObject = {}, - body: any = '', option: IDataObject = {}, ): Promise { const credentials = await this.getCredentials('jenkinsApi'); + const baseUrl = (credentials.baseUrl as string) || ''; + const headers: IDataObject = { + Accept: 'application/json', + }; + + if (['POST', 'PUT', 'DELETE'].includes(method)) { + const crumb = await getJenkinsCrumb.call(this, credentials); + if (crumb) { + Object.assign(headers, crumb); + } + } + + if (option.headers) { + Object.assign(headers, option.headers); + } + let options: IRequestOptions = { - headers: { - Accept: 'application/json', - }, + headers, method, auth: { username: credentials.username as string, password: credentials.apiKey as string, }, - uri: `${removeTrailingSlash(credentials.baseUrl as string)}${uri}`, + uri: `${removeTrailingSlash(baseUrl)}${uri}`, json: true, qs, body, }; - options = Object.assign({}, options, option); + options = Object.assign({}, options, option, { headers }); try { return await this.helpers.request(options); } catch (error) { + const rawStatusCode = + error.statusCode ?? error.httpCode ?? error.status ?? error.response?.status; + const statusCode = rawStatusCode !== undefined ? Number(rawStatusCode) : undefined; + const responseHeaders = (error.response?.headers ?? error.headers ?? {}) as Record< + string, + string | undefined + >; + const location = (responseHeaders.location ?? responseHeaders.Location) as string | undefined; + + if ( + statusCode !== undefined && + [201, 302, 303].includes(statusCode) && + typeof location === 'string' && + /\/queue\/item\/\d+/i.test(location) && + !location.includes('/login') + ) { + return { success: true, queueUrl: location }; + } + throw new NodeApiError(this.getNode(), error as JsonObject); } } diff --git a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts index 37316f818b9..831539c4d63 100644 --- a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts @@ -1,7 +1,7 @@ import type { IExecuteFunctions } from 'n8n-workflow'; import { mockDeep } from 'vitest-mock-extended'; -import { jenkinsApiRequest } from '../GenericFunctions'; +import { getJenkinsCrumb, jenkinsApiRequest } from '../GenericFunctions'; describe('jenkinsApiRequest', () => { it('removes a trailing slash from the credential base URL', async () => { @@ -18,4 +18,388 @@ describe('jenkinsApiRequest', () => { expect.objectContaining({ uri: 'https://jenkins.example.com/api/json' }), ); }); + + it('handles missing base URL gracefully without throwing', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + }); + + await jenkinsApiRequest.call(executeFunctions, 'GET', '/api/json'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ uri: '/api/json' }), + ); + }); + + it('fetches and attaches CSRF crumb and session cookie on POST requests when crumb issuer is available', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + body: { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }, + headers: { + 'set-cookie': ['JSESSIONID=test-session-cookie; Path=/'], + }, + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + uri: 'https://jenkins.example.com/job/my-job/build', + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + Cookie: 'JSESSIONID=test-session-cookie; Path=/', + }), + }), + ); + }); + + it('preserves caller option headers alongside the CSRF crumb', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + body: { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }, + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/buildWithParameters', + {}, + {}, + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }, + ); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/json', + 'Jenkins-Crumb': 'test-crumb-value-123', + 'content-type': 'application/x-www-form-urlencoded', + }), + }), + ); + }); + + it('fetches and attaches CSRF crumb on PUT and DELETE requests', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call(executeFunctions, 'PUT', '/job/my-job/config.xml'); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + }), + }), + ); + + await jenkinsApiRequest.call(executeFunctions, 'DELETE', '/job/my-job'); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + }), + }), + ); + }); + + it('proceeds without crumb on POST requests when crumb issuer returns 404', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + throw new Error('404 Not Found'); + } + return { success: true }; + }); + + const result = await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'); + + expect(result).toEqual({ success: true }); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + uri: 'https://jenkins.example.com/job/my-job/build', + }), + ); + }); + + it('does not attempt to fetch CSRF crumb for GET requests', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockResolvedValue({ status: 'ok' }); + + await jenkinsApiRequest.call(executeFunctions, 'GET', '/job/my-job/api/json'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledTimes(1); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + uri: 'https://jenkins.example.com/job/my-job/api/json', + }), + ); + }); + + it('treats 303 queue item redirect as successful build scheduling', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.statusCode = 303; + error.response = { + status: 303, + headers: { + location: 'https://jenkins.example.com/queue/item/42/', + }, + }; + throw error; + }); + + const result = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/buildWithParameters', + ); + + expect(result).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/42/', + }); + }); + + it('treats 302 and 201 queue item redirects as successful build scheduling', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('302 - Found'); + error.statusCode = 302; + error.response = { + status: 302, + headers: { + location: 'https://jenkins.example.com/queue/item/99/', + }, + }; + throw error; + }); + + const result302 = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result302).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/99/', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('201 - Created'); + error.statusCode = 201; + error.response = { + status: 201, + headers: { + location: 'https://jenkins.example.com/queue/item/100/', + }, + }; + throw error; + }); + + const result201 = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result201).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/100/', + }); + }); + + it('handles string status codes in redirect responses', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.httpCode = '303'; + error.headers = { + location: 'https://jenkins.example.com/queue/item/77/', + }; + throw error; + }); + + const result = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/77/', + }); + }); + + it('rethrows NodeApiError when 303 redirects to login page instead of queue', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.statusCode = 303; + error.response = { + status: 303, + headers: { + location: 'https://jenkins.example.com/login?from=/job/my-job/build', + }, + }; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/buildWithParameters'), + ).rejects.toThrow(); + }); + + it('rethrows NodeApiError when redirect contains /login even if /queue/item/ is in query string', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('302 - Found'); + error.statusCode = 302; + error.response = { + status: 302, + headers: { + location: 'https://jenkins.example.com/login?from=/queue/item/42/', + }, + }; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'), + ).rejects.toThrow(); + }); + + it('rethrows NodeApiError when a non-redirect error occurs', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('500 - Internal Server Error'); + error.statusCode = 500; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'), + ).rejects.toThrow(); + }); }); diff --git a/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts b/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts index 526f122df0c..99394f992ef 100644 --- a/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts +++ b/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts @@ -1,5 +1,9 @@ import { mockDeep } from 'vitest-mock-extended'; -import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; import * as GenericFunctions from '../GenericFunctions'; import { Jenkins } from '../Jenkins.node'; @@ -120,4 +124,61 @@ describe('Jenkins node', () => { ]); }); }); + + describe('execute', () => { + it('triggers a job with parameters successfully when jenkinsApiRequest returns queue redirect', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getInputData.mockReturnValue([{ json: {} }]); + executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) => + data.map((item: any) => ({ json: item })), + ); + executeFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'resource') return 'job'; + if (name === 'operation') return 'triggerParams'; + if (name === 'job') return 'demo-job'; + if (name === 'param.params') return [{ name: 'BRANCH', value: 'main' }]; + return undefined; + }); + jenkinsApiRequestSpy.mockResolvedValue({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/42/', + }); + + const result = await node.execute.call(executeFunctions); + + expect(jenkinsApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/job/demo-job/buildWithParameters', + {}, + {}, + { + form: { BRANCH: 'main' }, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }, + ); + expect(result).toEqual([[{ json: { success: true } }]]); + }); + + it('triggers a parameterless job successfully', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getInputData.mockReturnValue([{ json: {} }]); + executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) => + data.map((item: any) => ({ json: item })), + ); + executeFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'resource') return 'job'; + if (name === 'operation') return 'trigger'; + if (name === 'job') return 'demo-job'; + return undefined; + }); + jenkinsApiRequestSpy.mockResolvedValue({ success: true }); + + const result = await node.execute.call(executeFunctions); + + expect(jenkinsApiRequestSpy).toHaveBeenCalledWith('POST', '/job/demo-job/build'); + expect(result).toEqual([[{ json: { success: true } }]]); + }); + }); }); From a0357c14abd12c13b6d760d16237a70e7c6c398b Mon Sep 17 00:00:00 2001 From: TemoSulava Date: Fri, 16 Oct 2026 21:43:45 +0400 Subject: [PATCH 2/2] fix(Jenkins Node): Strip cookie attributes and disable credentials on cross-origin redirect --- .../nodes/Jenkins/GenericFunctions.ts | 8 +++-- .../Jenkins/test/GenericFunctions.test.ts | 36 ++++++++++--------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts index b13bb64994e..06813326b8a 100644 --- a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts @@ -25,6 +25,7 @@ export async function getJenkinsCrumb( username: credentials.username as string, password: credentials.apiKey as string, }, + sendCredentialsOnCrossOriginRedirect: false, uri: `${removeTrailingSlash(baseUrl)}/crumbIssuer/api/json`, json: true, resolveWithFullResponse: true, @@ -41,7 +42,9 @@ export async function getJenkinsCrumb( }; const setCookie = response.headers?.['set-cookie']; if (setCookie) { - crumbHeaders.Cookie = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + crumbHeaders.Cookie = (Array.isArray(setCookie) ? setCookie : [setCookie]) + .map((cookie) => cookie.split(';', 1)[0]) + .join('; '); } return crumbHeaders; } @@ -83,6 +86,7 @@ export async function jenkinsApiRequest( username: credentials.username as string, password: credentials.apiKey as string, }, + sendCredentialsOnCrossOriginRedirect: false, uri: `${removeTrailingSlash(baseUrl)}${uri}`, json: true, qs, @@ -103,7 +107,7 @@ export async function jenkinsApiRequest( if ( statusCode !== undefined && - [201, 302, 303].includes(statusCode) && + [302, 303].includes(statusCode) && typeof location === 'string' && /\/queue\/item\/\d+/i.test(location) && !location.includes('/login') diff --git a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts index 831539c4d63..7b62753ac8d 100644 --- a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts @@ -49,7 +49,10 @@ describe('jenkinsApiRequest', () => { crumb: 'test-crumb-value-123', }, headers: { - 'set-cookie': ['JSESSIONID=test-session-cookie; Path=/'], + 'set-cookie': [ + 'JSESSIONID=test-session-cookie; Path=/; HttpOnly', + 'remember-me=test-token; Path=/; Max-Age=1209600', + ], }, }; } @@ -62,9 +65,10 @@ describe('jenkinsApiRequest', () => { expect.objectContaining({ method: 'POST', uri: 'https://jenkins.example.com/job/my-job/build', + sendCredentialsOnCrossOriginRedirect: false, headers: expect.objectContaining({ 'Jenkins-Crumb': 'test-crumb-value-123', - Cookie: 'JSESSIONID=test-session-cookie; Path=/', + Cookie: 'JSESSIONID=test-session-cookie; remember-me=test-token', }), }), ); @@ -235,7 +239,7 @@ describe('jenkinsApiRequest', () => { }); }); - it('treats 302 and 201 queue item redirects as successful build scheduling', async () => { + it('treats 302 queue item redirect as successful build scheduling', async () => { const executeFunctions = mockDeep(); executeFunctions.getCredentials.mockResolvedValue({ username: 'user', @@ -267,31 +271,29 @@ describe('jenkinsApiRequest', () => { success: true, queueUrl: 'https://jenkins.example.com/queue/item/99/', }); + }); + + it('returns resolved response when job trigger resolves successfully', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); executeFunctions.helpers.request.mockImplementation(async (options: any) => { if (options.uri.endsWith('/crumbIssuer/api/json')) { return null; } - const error: any = new Error('201 - Created'); - error.statusCode = 201; - error.response = { - status: 201, - headers: { - location: 'https://jenkins.example.com/queue/item/100/', - }, - }; - throw error; + return { success: true }; }); - const result201 = await jenkinsApiRequest.call( + const result = await jenkinsApiRequest.call( executeFunctions, 'POST', '/job/my-job/build', ); - expect(result201).toEqual({ - success: true, - queueUrl: 'https://jenkins.example.com/queue/item/100/', - }); + expect(result).toEqual({ success: true }); }); it('handles string status codes in redirect responses', async () => {