diff --git a/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-query.dto.test.ts b/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-query.dto.test.ts new file mode 100644 index 0000000000..b857292197 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-query.dto.test.ts @@ -0,0 +1,85 @@ +import { BinaryDataQueryDto } from '../binary-data-query.dto'; + +describe('BinaryDataQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'filesystem mode with view action', + request: { + id: 'filesystem:some-id', + action: 'view', + }, + }, + { + name: 'filesystem-v2 mode with download action', + request: { + id: 'filesystem-v2:some-id', + action: 'download', + }, + }, + { + name: 's3 mode with view action and optional fields', + request: { + id: 's3:some-id', + action: 'view', + fileName: 'test.pdf', + mimeType: 'application/pdf', + }, + }, + ])('should validate $name', ({ request }) => { + const result = BinaryDataQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing mode separator', + request: { + id: 'filesystemsome-id', + action: 'view', + }, + expectedErrorPath: ['id'], + }, + { + name: 'invalid mode', + request: { + id: 'invalid:some-id', + action: 'view', + }, + expectedErrorPath: ['id'], + }, + { + name: 'invalid action', + request: { + id: 'filesystem:some-id', + action: 'invalid', + }, + expectedErrorPath: ['action'], + }, + { + name: 'missing id', + request: { + action: 'view', + }, + expectedErrorPath: ['id'], + }, + { + name: 'missing action', + request: { + id: 'filesystem:some-id', + }, + expectedErrorPath: ['action'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = BinaryDataQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-signed-query.dto.test.ts b/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-signed-query.dto.test.ts new file mode 100644 index 0000000000..c7d650d828 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/binary-data/__tests__/binary-data-signed-query.dto.test.ts @@ -0,0 +1,71 @@ +import { BinaryDataSignedQueryDto } from '../binary-data-signed-query.dto'; + +describe('BinaryDataSignedQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'valid JWT token', + request: { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + }, + }, + ])('should validate $name', ({ request }) => { + const result = BinaryDataSignedQueryDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing token', + request: {}, + expectedErrorPath: ['token'], + }, + { + name: 'empty token', + request: { + token: '', + }, + expectedErrorPath: ['token'], + }, + { + name: 'non-string token', + request: { + token: 123, + }, + expectedErrorPath: ['token'], + }, + { + name: 'token without three segments', + request: { + token: 'header.payload', + }, + expectedErrorPath: ['token'], + }, + { + name: 'token with invalid characters', + request: { + token: 'header.payload.sign@ture', + }, + expectedErrorPath: ['token'], + }, + { + name: 'token with too many segments', + request: { + token: 'header.payload.signature.extra', + }, + expectedErrorPath: ['token'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = BinaryDataSignedQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/binary-data/binary-data-query.dto.ts b/packages/@n8n/api-types/src/dto/binary-data/binary-data-query.dto.ts new file mode 100644 index 0000000000..37fbc2f120 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/binary-data/binary-data-query.dto.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class BinaryDataQueryDto extends Z.class({ + id: z + .string() + .refine((id) => id.includes(':'), { + message: 'Missing binary data mode', + }) + .refine( + (id) => { + const [mode] = id.split(':'); + return ['filesystem', 'filesystem-v2', 's3'].includes(mode); + }, + { + message: 'Invalid binary data mode', + }, + ), + action: z.enum(['view', 'download']), + fileName: z.string().optional(), + mimeType: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/binary-data/binary-data-signed-query.dto.ts b/packages/@n8n/api-types/src/dto/binary-data/binary-data-signed-query.dto.ts new file mode 100644 index 0000000000..a10bf6b9ae --- /dev/null +++ b/packages/@n8n/api-types/src/dto/binary-data/binary-data-signed-query.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class BinaryDataSignedQueryDto extends Z.class({ + token: z.string().regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/, { + message: 'Token must be a valid JWT format', + }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 3fd35441b8..2be1471b72 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -3,6 +3,9 @@ export { AiChatRequestDto } from './ai/ai-chat-request.dto'; export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto'; +export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto'; +export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto'; + export { LoginRequestDto } from './auth/login-request.dto'; export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; diff --git a/packages/cli/package.json b/packages/cli/package.json index f3ec48411a..751411fd66 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,7 @@ "@types/flat": "^5.0.5", "@types/formidable": "^3.4.5", "@types/json-diff": "^1.0.0", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "catalog:", "@types/lodash": "catalog:", "@types/psl": "^1.1.0", "@types/replacestream": "^4.0.1", @@ -132,7 +132,7 @@ "isbot": "3.6.13", "json-diff": "1.0.6", "jsonschema": "1.4.1", - "jsonwebtoken": "9.0.2", + "jsonwebtoken": "catalog:", "ldapts": "4.2.6", "lodash": "catalog:", "luxon": "catalog:", diff --git a/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts b/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts index ac18f79860..b0170ba523 100644 --- a/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts @@ -1,15 +1,15 @@ -import type { Response } from 'express'; +import type { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types'; +import type { Request, Response } from 'express'; import { mock } from 'jest-mock-extended'; +import { JsonWebTokenError } from 'jsonwebtoken'; import type { BinaryDataService } from 'n8n-core'; import { FileNotFoundError } from 'n8n-core'; import type { Readable } from 'node:stream'; -import type { BinaryDataRequest } from '@/requests'; - import { BinaryDataController } from '../binary-data.controller'; describe('BinaryDataController', () => { - const request = mock(); + const request = mock(); const response = mock(); const binaryDataService = mock(); const controller = new BinaryDataController(binaryDataService); @@ -20,55 +20,45 @@ describe('BinaryDataController', () => { }); describe('get', () => { - it('should return 400 if binary data ID is missing', async () => { - // @ts-expect-error invalid query object - request.query = {}; - - await controller.get(request, response); - - expect(response.status).toHaveBeenCalledWith(400); - expect(response.end).toHaveBeenCalledWith('Missing binary data ID'); - }); - it('should return 400 if binary data mode is missing', async () => { - request.query = { id: '123', action: 'view' }; + const query = { id: '123', action: 'view' } as BinaryDataQueryDto; - await controller.get(request, response); + await controller.get(request, response, query); expect(response.status).toHaveBeenCalledWith(400); expect(response.end).toHaveBeenCalledWith('Missing binary data mode'); }); it('should return 400 if binary data mode is invalid', async () => { - request.query = { id: 'invalidMode:123', action: 'view' }; + const query = { id: 'invalidMode:123', action: 'view' } as BinaryDataQueryDto; - await controller.get(request, response); + await controller.get(request, response, query); expect(response.status).toHaveBeenCalledWith(400); expect(response.end).toHaveBeenCalledWith('Invalid binary data mode'); }); it('should return 404 if file is not found', async () => { - request.query = { id: 'filesystem:123', action: 'view' }; + const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto; binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found')); - await controller.get(request, response); + await controller.get(request, response, query); expect(response.status).toHaveBeenCalledWith(404); expect(response.end).toHaveBeenCalled(); }); it('should set Content-Type and Content-Length from query if provided', async () => { - request.query = { + const query = { id: 'filesystem:123', action: 'view', fileName: 'test.txt', mimeType: 'text/plain', - }; + } as BinaryDataQueryDto; binaryDataService.getAsStream.mockResolvedValue(mock()); - await controller.get(request, response); + await controller.get(request, response, query); expect(binaryDataService.getMetadata).not.toHaveBeenCalled(); expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); @@ -77,7 +67,7 @@ describe('BinaryDataController', () => { }); it('should set Content-Type and Content-Length from metadata if not provided', async () => { - request.query = { id: 'filesystem:123', action: 'view' }; + const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto; binaryDataService.getMetadata.mockResolvedValue({ fileName: 'test.txt', @@ -86,7 +76,7 @@ describe('BinaryDataController', () => { }); binaryDataService.getAsStream.mockResolvedValue(mock()); - await controller.get(request, response); + await controller.get(request, response, query); expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123'); expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); @@ -95,11 +85,15 @@ describe('BinaryDataController', () => { }); it('should set Content-Disposition for download action', async () => { - request.query = { id: 'filesystem:123', action: 'download', fileName: 'test.txt' }; + const query = { + id: 'filesystem:123', + action: 'download', + fileName: 'test.txt', + } as BinaryDataQueryDto; binaryDataService.getAsStream.mockResolvedValue(mock()); - await controller.get(request, response); + await controller.get(request, response, query); expect(response.setHeader).toHaveBeenCalledWith( 'Content-Disposition', @@ -108,42 +102,95 @@ describe('BinaryDataController', () => { }); it('should set Content-Security-Policy for HTML in view mode', async () => { - request.query = { + const query = { id: 'filesystem:123', action: 'view', fileName: 'test.html', mimeType: 'text/html', - }; + } as BinaryDataQueryDto; binaryDataService.getAsStream.mockResolvedValue(mock()); - await controller.get(request, response); + await controller.get(request, response, query); expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox'); }); it('should not set Content-Security-Policy for HTML in download mode', async () => { - request.query = { + const query = { id: 'filesystem:123', action: 'download', fileName: 'test.html', mimeType: 'text/html', - }; + } as BinaryDataQueryDto; binaryDataService.getAsStream.mockResolvedValue(mock()); - await controller.get(request, response); + await controller.get(request, response, query); expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox'); }); it('should return the file stream on success', async () => { - request.query = { id: 'filesystem:123', action: 'view' }; + const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto; const stream = mock(); binaryDataService.getAsStream.mockResolvedValue(stream); - const result = await controller.get(request, response); + const result = await controller.get(request, response, query); + + expect(result).toBe(stream); + expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123'); + }); + }); + + describe('getSigned', () => { + const query = { token: '12344' } as BinaryDataSignedQueryDto; + + it('should return 400 if binary data mode is missing', async () => { + binaryDataService.validateSignedToken.mockReturnValueOnce('123'); + await controller.getSigned(request, response, query); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.end).toHaveBeenCalledWith('Missing binary data mode'); + }); + + it('should return 400 if binary data mode is invalid', async () => { + binaryDataService.validateSignedToken.mockReturnValueOnce('invalid:123'); + + await controller.getSigned(request, response, query); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.end).toHaveBeenCalledWith('Invalid binary data mode'); + }); + + it('should return 400 if token is invalid', async () => { + binaryDataService.validateSignedToken.mockImplementation(() => { + throw new JsonWebTokenError('Invalid token'); + }); + + await controller.getSigned(request, response, query); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.end).toHaveBeenCalledWith('Invalid token'); + }); + + it('should return 404 if file is not found', async () => { + binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123'); + binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found')); + + await controller.getSigned(request, response, query); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.end).toHaveBeenCalled(); + }); + + it('should return the file stream on a valid signed token', async () => { + binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123'); + const stream = mock(); + binaryDataService.getAsStream.mockResolvedValue(stream); + + const result = await controller.getSigned(request, response, query); expect(result).toBe(stream); expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123'); diff --git a/packages/cli/src/controllers/binary-data.controller.ts b/packages/cli/src/controllers/binary-data.controller.ts index 1b009fd44b..c0cb2dbff6 100644 --- a/packages/cli/src/controllers/binary-data.controller.ts +++ b/packages/cli/src/controllers/binary-data.controller.ts @@ -1,61 +1,90 @@ -import express from 'express'; +import { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types'; +import { Request, Response } from 'express'; +import { JsonWebTokenError } from 'jsonwebtoken'; import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core'; -import { Get, RestController } from '@/decorators'; -import { BinaryDataRequest } from '@/requests'; +import { Get, Query, RestController } from '@/decorators'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @RestController('/binary-data') export class BinaryDataController { constructor(private readonly binaryDataService: BinaryDataService) {} @Get('/') - async get(req: BinaryDataRequest, res: express.Response) { - const { id: binaryDataId, action } = req.query; - - if (!binaryDataId) { - return res.status(400).end('Missing binary data ID'); - } - - if (!binaryDataId.includes(':')) { - return res.status(400).end('Missing binary data mode'); - } - - const [mode] = binaryDataId.split(':'); - - if (!isValidNonDefaultMode(mode)) { - return res.status(400).end('Invalid binary data mode'); - } - - let { fileName, mimeType } = req.query; - + async get( + _: Request, + res: Response, + @Query { id: binaryDataId, action, fileName, mimeType }: BinaryDataQueryDto, + ) { try { - if (!fileName || !mimeType) { - try { - const metadata = await this.binaryDataService.getMetadata(binaryDataId); - fileName = metadata.fileName; - mimeType = metadata.mimeType; - res.setHeader('Content-Length', metadata.fileSize); - } catch {} - } - - if (mimeType) { - res.setHeader('Content-Type', mimeType); - - // Sandbox html files when viewed in a browser - if (mimeType.includes('html') && action === 'view') { - res.header('Content-Security-Policy', 'sandbox'); - } - } - - if (action === 'download' && fileName) { - const encodedFilename = encodeURIComponent(fileName); - res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`); - } - + this.validateBinaryDataId(binaryDataId); + await this.setContentHeaders(binaryDataId, action, res, fileName, mimeType); return await this.binaryDataService.getAsStream(binaryDataId); } catch (error) { if (error instanceof FileNotFoundError) return res.status(404).end(); + if (error instanceof BadRequestError) return res.status(400).end(error.message); else throw error; } } + + @Get('/signed', { skipAuth: true }) + async getSigned(_: Request, res: Response, @Query { token }: BinaryDataSignedQueryDto) { + try { + const binaryDataId = this.binaryDataService.validateSignedToken(token); + this.validateBinaryDataId(binaryDataId); + await this.setContentHeaders(binaryDataId, 'download', res); + return await this.binaryDataService.getAsStream(binaryDataId); + } catch (error) { + if (error instanceof FileNotFoundError) return res.status(404).end(); + if (error instanceof BadRequestError || error instanceof JsonWebTokenError) + return res.status(400).end(error.message); + else throw error; + } + } + + private validateBinaryDataId(binaryDataId: string) { + if (!binaryDataId) { + throw new BadRequestError('Missing binary data ID'); + } + + if (!binaryDataId.includes(':')) { + throw new BadRequestError('Missing binary data mode'); + } + + const [mode] = binaryDataId.split(':'); + if (!isValidNonDefaultMode(mode)) { + throw new BadRequestError('Invalid binary data mode'); + } + } + + private async setContentHeaders( + binaryDataId: string, + action: 'view' | 'download', + res: Response, + fileName?: string, + mimeType?: string, + ) { + if (!fileName || !mimeType) { + try { + const metadata = await this.binaryDataService.getMetadata(binaryDataId); + fileName = metadata.fileName; + mimeType = metadata.mimeType; + res.setHeader('Content-Length', metadata.fileSize); + } catch {} + } + + if (mimeType) { + res.setHeader('Content-Type', mimeType); + + // Sandbox html files when viewed in a browser + if (mimeType.includes('html') && action === 'view') { + res.header('Content-Security-Policy', 'sandbox'); + } + } + + if (action === 'download' && fileName) { + const encodedFilename = encodeURIComponent(fileName); + res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`); + } + } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 412a2370c1..6cd0b95203 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -296,18 +296,6 @@ export declare namespace LicenseRequest { type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>; } -export type BinaryDataRequest = AuthenticatedRequest< - {}, - {}, - {}, - { - id: string; - action: 'view' | 'download'; - fileName?: string; - mimeType?: string; - } ->; - // ---------------------------------- // /variables // ---------------------------------- diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 7c9eebf657..54b1605f90 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -110,7 +110,7 @@ export async function initNodeTypes(customNodes?: INodeTypeData) { * Initialize a BinaryDataService for test runs. */ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { - const binaryDataService = new BinaryDataService(); + const binaryDataService = new BinaryDataService(mock(), mock()); await binaryDataService.init({ mode, availableModes: [mode], diff --git a/packages/core/package.json b/packages/core/package.json index e7b24b4ec0..3445b3948e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", "@types/express": "catalog:", + "@types/jsonwebtoken": "catalog:", "@types/lodash": "catalog:", "@types/mime-types": "^2.1.0", "@types/uuid": "catalog:", @@ -52,6 +53,7 @@ "file-type": "16.5.4", "form-data": "catalog:", "iconv-lite": "catalog:", + "jsonwebtoken": "catalog:", "lodash": "catalog:", "luxon": "catalog:", "mime-types": "2.1.35", diff --git a/packages/core/src/binary-data/__tests__/binary-data-service.test.ts b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts new file mode 100644 index 0000000000..d8b166c23a --- /dev/null +++ b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts @@ -0,0 +1,75 @@ +import { mock } from 'jest-mock-extended'; +import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; +import type { IBinaryData } from 'n8n-workflow'; + +import { type InstanceSettings } from '@/instance-settings'; + +import type { BinaryDataConfig } from '../binary-data.config'; +import { BinaryDataService } from '../binary-data.service'; + +const now = new Date('2025-01-01T01:23:45.678Z'); +jest.useFakeTimers({ now }); + +describe('BinaryDataService', () => { + const signingSecret = 'test-signing-secret'; + const config = mock({ signingSecret }); + const instanceSettings = mock({ encryptionKey: 'test-encryption-key' }); + const binaryData = mock({ id: 'filesystem:id_123' }); + const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' }); + + let service: BinaryDataService; + beforeEach(() => { + jest.resetAllMocks(); + + config.signingSecret = signingSecret; + service = new BinaryDataService(instanceSettings, config); + }); + + describe('constructor', () => { + it('should derive the signingSecret from the encryption-key, if not provided via BinaryDataConfig', () => { + config.signingSecret = undefined; + + const service = new BinaryDataService(instanceSettings, config); + + expect(service.signingSecret).toBe( + 'f7a78761c5cc17a2753e7e9d85d90e12de87d8131aea4479a11d1c7bb9655b20', + ); + }); + + it('should use signingSecret as provided in BinaryDataConfig', () => { + expect(service.signingSecret).toBe(signingSecret); + }); + }); + + describe('createSignedToken', () => { + it('should throw for binary-data without an id', () => { + const binaryData = mock({ id: undefined }); + + expect(() => service.createSignedToken(binaryData)).toThrow(); + }); + + it('should create a signed token for valid binary-data', () => { + const token = service.createSignedToken(binaryData); + + expect(token).toBe(validToken); + }); + }); + + describe('validateSignedToken', () => { + const invalidToken = sign({ id: binaryData.id }, 'fake-secret'); + const expiredToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '-1 day' }); + + it('should throw on invalid tokens', () => { + expect(() => service.validateSignedToken(invalidToken)).toThrow(JsonWebTokenError); + }); + + it('should throw on expired tokens', () => { + expect(() => service.validateSignedToken(expiredToken)).toThrow(TokenExpiredError); + }); + + it('should return binary-data id on valid tokens', () => { + const result = service.validateSignedToken(validToken); + expect(result).toBe(binaryData.id); + }); + }); +}); diff --git a/packages/core/src/binary-data/binary-data.config.ts b/packages/core/src/binary-data/binary-data.config.ts new file mode 100644 index 0000000000..95598cf92b --- /dev/null +++ b/packages/core/src/binary-data/binary-data.config.ts @@ -0,0 +1,8 @@ +import { Config, Env } from '@n8n/config'; + +@Config +export class BinaryDataConfig { + /** Secret for creating publicly-accesible signed URLs for binary data */ + @Env('N8N_BINARY_DATA_SIGNING_SECRET') + signingSecret?: string = undefined; +} diff --git a/packages/core/src/binary-data/binary-data.service.ts b/packages/core/src/binary-data/binary-data.service.ts index 742ceb500f..bc28ce3d7d 100644 --- a/packages/core/src/binary-data/binary-data.service.ts +++ b/packages/core/src/binary-data/binary-data.service.ts @@ -1,10 +1,15 @@ import { Container, Service } from '@n8n/di'; -import { BINARY_ENCODING } from 'n8n-workflow'; +import { createHash } from 'crypto'; +import jwt from 'jsonwebtoken'; +import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow'; import type { INodeExecutionData, IBinaryData } from 'n8n-workflow'; import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; +import { InstanceSettings } from '@/instance-settings'; + +import { BinaryDataConfig } from './binary-data.config'; import type { BinaryData } from './types'; import { areConfigModes, binaryToBuffer } from './utils'; import { InvalidManagerError } from '../errors/invalid-manager.error'; @@ -16,6 +21,14 @@ export class BinaryDataService { private managers: Record = {}; + readonly signingSecret: string; + + constructor({ encryptionKey }: InstanceSettings, binaryDataConfig: BinaryDataConfig) { + this.signingSecret = + binaryDataConfig.signingSecret ?? + createHash('sha256').update(`url-signing:${encryptionKey}`).digest('hex'); + } + async init(config: BinaryData.Config) { if (!areConfigModes(config.availableModes)) throw new InvalidModeError(); @@ -40,6 +53,23 @@ export class BinaryDataService { } } + createSignedToken(binaryData: IBinaryData, expiresIn = '1 day') { + if (!binaryData.id) { + throw new UnexpectedError('URL signing is not available in memory mode'); + } + + const signingPayload: BinaryData.SigningPayload = { + id: binaryData.id, + }; + + return jwt.sign(signingPayload, this.signingSecret, { expiresIn }); + } + + validateSignedToken(token: string) { + const signedPayload = jwt.verify(token, this.signingSecret) as BinaryData.SigningPayload; + return signedPayload.id; + } + async copyBinaryFile( workflowId: string, executionId: string, diff --git a/packages/core/src/binary-data/types.ts b/packages/core/src/binary-data/types.ts index ef39197b3e..12db5ef7ec 100644 --- a/packages/core/src/binary-data/types.ts +++ b/packages/core/src/binary-data/types.ts @@ -70,4 +70,8 @@ export namespace BinaryData { rename(oldFileId: string, newFileId: string): Promise; } + + export type SigningPayload = { + id: string; + }; } diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts index a250421cdf..5815990208 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts @@ -13,6 +13,7 @@ import { join } from 'path'; import { Readable } from 'stream'; import { BinaryDataService } from '@/binary-data/binary-data.service'; +import { type InstanceSettings } from '@/instance-settings'; import { assertBinaryData, @@ -41,7 +42,7 @@ describe('test binary data helper methods', () => { const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); beforeEach(() => { - binaryDataService = new BinaryDataService(); + binaryDataService = new BinaryDataService(mock(), mock()); Container.set(BinaryDataService, binaryDataService); }); @@ -478,3 +479,24 @@ describe('getBinaryHelperFunctions', () => { ); }); }); + +describe('createBinarySignedUrl', () => { + const restApiUrl = 'https://n8n.host/rest'; + + it('should get a signed url', async () => { + const additionalData = { restApiUrl } as IWorkflowExecuteAdditionalData; + const helperFunctions = getBinaryHelperFunctions(additionalData, workflowId); + + const binaryData = mock(); + const token = 'signed-token'; + + const binaryDataService = mock(); + Container.set(BinaryDataService, binaryDataService); + binaryDataService.createSignedToken.mockReturnValueOnce(token); + + const result = helperFunctions.createBinarySignedUrl(binaryData); + + expect(result).toBe(`${restApiUrl}/binary-data/signed?token=${token}`); + expect(binaryDataService.createSignedToken).toHaveBeenCalledWith(binaryData, undefined); + }); +}); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts index c1bbff7899..d111a93be5 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts @@ -271,7 +271,7 @@ export async function prepareBinaryData( } export const getBinaryHelperFunctions = ( - { executionId }: IWorkflowExecuteAdditionalData, + { executionId, restApiUrl }: IWorkflowExecuteAdditionalData, workflowId: string, ): BinaryHelperFunctions => ({ getBinaryPath, @@ -279,6 +279,10 @@ export const getBinaryHelperFunctions = ( getBinaryMetadata, binaryToBuffer, binaryToString, + createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string) { + const token = Container.get(BinaryDataService).createSignedToken(binaryData, expiresIn); + return `${restApiUrl}/binary-data/signed?token=${token}`; + }, prepareBinaryData: async (binaryData, filePath, mimeType) => await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1403410e1b..0d0e1fdef6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -848,7 +848,7 @@ "@types/html-to-text": "^9.0.1", "@types/gm": "^1.25.0", "@types/js-nacl": "^1.3.0", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "catalog:", "@types/lodash": "catalog:", "@types/lossless-json": "^1.0.0", "@types/mailparser": "^3.4.4", @@ -895,7 +895,7 @@ "isbot": "3.6.13", "iso-639-1": "2.1.15", "js-nacl": "1.4.0", - "jsonwebtoken": "9.0.2", + "jsonwebtoken": "catalog:", "kafkajs": "2.2.4", "ldapts": "4.2.6", "lodash": "catalog:", diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index a92c742994..180c243f4b 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -1,5 +1,6 @@ import { Container } from '@n8n/di'; import { readFileSync, readdirSync, mkdtempSync } from 'fs'; +import { mock } from 'jest-mock-extended'; import { get } from 'lodash'; import { isEmpty } from 'lodash'; import { BinaryDataService, constructExecutionMetaData } from 'n8n-core'; @@ -36,7 +37,7 @@ export function createTemporaryDir(prefix = 'n8n') { } export async function initBinaryDataService() { - const binaryDataService = new BinaryDataService(); + const binaryDataService = new BinaryDataService(mock(), mock()); await binaryDataService.init({ mode: 'default', availableModes: ['default'], diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ba020ac3d6..493e49baa8 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -690,6 +690,7 @@ export interface BinaryHelperFunctions { binaryToString(body: Buffer | Readable, encoding?: BufferEncoding): Promise; getBinaryPath(binaryDataId: string): string; getBinaryStream(binaryDataId: string, chunkSize?: number): Promise; + createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string): string; getBinaryMetadata(binaryDataId: string): Promise<{ fileName?: string; mimeType?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74ed5d960a..731ab5eef9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@types/express': specifier: ^5.0.1 version: 5.0.1 + '@types/jsonwebtoken': + specifier: ^9.0.9 + version: 9.0.9 '@types/lodash': specifier: ^4.14.195 version: 4.14.195 @@ -48,6 +51,9 @@ catalogs: iconv-lite: specifier: 0.6.3 version: 0.6.3 + jsonwebtoken: + specifier: 9.0.2 + version: 9.0.2 lodash: specifier: 4.17.21 version: 4.17.21 @@ -552,7 +558,7 @@ importers: version: 3.666.0(@aws-sdk/client-sts@3.666.0) '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0)) + version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -579,7 +585,7 @@ importers: version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13) '@langchain/community': specifier: 0.3.24 - version: 0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5) + version: 0.3.24(bae0580ee8bea2ce19e4657a460c92d0) '@langchain/core': specifier: 'catalog:' version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) @@ -678,7 +684,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.11 - version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) lodash: specifier: 'catalog:' version: 4.17.21 @@ -1035,7 +1041,7 @@ importers: specifier: 1.4.1 version: 1.4.1 jsonwebtoken: - specifier: 9.0.2 + specifier: 'catalog:' version: 9.0.2 ldapts: specifier: 4.2.6 @@ -1195,8 +1201,8 @@ importers: specifier: ^1.0.0 version: 1.0.0 '@types/jsonwebtoken': - specifier: ^9.0.6 - version: 9.0.6 + specifier: 'catalog:' + version: 9.0.9 '@types/lodash': specifier: 'catalog:' version: 4.14.195 @@ -1302,6 +1308,9 @@ importers: iconv-lite: specifier: 'catalog:' version: 0.6.3 + jsonwebtoken: + specifier: 'catalog:' + version: 9.0.2 lodash: specifier: 'catalog:' version: 4.17.21 @@ -1360,6 +1369,9 @@ importers: '@types/express': specifier: 'catalog:' version: 5.0.1 + '@types/jsonwebtoken': + specifier: 'catalog:' + version: 9.0.9 '@types/lodash': specifier: 'catalog:' version: 4.14.195 @@ -2083,7 +2095,7 @@ importers: specifier: 1.4.0 version: 1.4.0 jsonwebtoken: - specifier: 9.0.2 + specifier: 'catalog:' version: 9.0.2 kafkajs: specifier: 2.2.4 @@ -2231,8 +2243,8 @@ importers: specifier: ^1.3.0 version: 1.3.0 '@types/jsonwebtoken': - specifier: ^9.0.6 - version: 9.0.6 + specifier: 'catalog:' + version: 9.0.9 '@types/lodash': specifier: 'catalog:' version: 4.14.195 @@ -6066,8 +6078,8 @@ packages: '@types/jsonpath@0.2.0': resolution: {integrity: sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==} - '@types/jsonwebtoken@9.0.6': - resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + '@types/jsonwebtoken@9.0.9': + resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} '@types/k6@0.52.0': resolution: {integrity: sha512-yaw2wg61nKQtToDML+nngzgXVjZ6wNA4R0Q3jKDTeadG5EqfZgis5a1Q2hwY7kjuGuXmu8eM6gHg3tgnOj4vNw==} @@ -16137,7 +16149,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))': dependencies: form-data: 4.0.0 node-fetch: 2.7.0(encoding@0.1.13) @@ -16146,7 +16158,7 @@ snapshots: zod: 3.24.1 optionalDependencies: '@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) - langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) transitivePeerDependencies: - encoding @@ -16661,7 +16673,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)': + '@langchain/community@0.3.24(bae0580ee8bea2ce19e4657a460c92d0)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1) '@ibm-cloud/watsonx-ai': 1.1.2 @@ -16672,7 +16684,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.1.0 js-yaml: 4.1.0 - langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) openai: 4.78.1(encoding@0.1.13)(zod@3.24.1) uuid: 10.0.0 @@ -16687,7 +16699,7 @@ snapshots: '@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0) '@azure/storage-blob': 12.18.0(encoding@0.1.13) '@browserbasehq/sdk': 2.0.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -18933,8 +18945,9 @@ snapshots: '@types/jsonpath@0.2.0': {} - '@types/jsonwebtoken@9.0.6': + '@types/jsonwebtoken@9.0.9': dependencies: + '@types/ms': 0.7.34 '@types/node': 18.16.16 '@types/k6@0.52.0': {} @@ -22878,7 +22891,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 18.16.16 '@types/tough-cookie': 4.0.2 - axios: 1.8.2(debug@4.4.0) + axios: 1.8.2 camelcase: 6.3.0 debug: 4.4.0(supports-color@8.1.1) dotenv: 16.4.5 @@ -22888,7 +22901,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.8.2) + retry-axios: 2.6.0(axios@1.8.2(debug@4.4.0)) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -23882,7 +23895,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0): + langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a): dependencies: '@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) '@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13) @@ -26264,7 +26277,7 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - retry-axios@2.6.0(axios@1.8.2): + retry-axios@2.6.0(axios@1.8.2(debug@4.4.0)): dependencies: axios: 1.8.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4bc8280116..157b01e6b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ catalog: '@sentry/node': 8.52.1 '@types/basic-auth': ^1.1.3 '@types/express': ^5.0.1 + '@types/jsonwebtoken': ^9.0.9 '@types/lodash': ^4.14.195 '@types/uuid': ^10.0.0 '@types/xml2js': ^0.4.14 @@ -20,6 +21,7 @@ catalog: flatted: 3.2.7 form-data: 4.0.0 iconv-lite: 0.6.3 + jsonwebtoken: 9.0.2 lodash: 4.17.21 luxon: 3.4.4 nanoid: 3.3.8