feat(core): Add support for signed URLs for binary data (#14492)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Dana
2025-04-14 19:59:40 +02:00
committed by GitHub
parent 23f25cefbf
commit 7723a138a1
22 changed files with 537 additions and 122 deletions

View File

@@ -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<BinaryDataConfig>({ signingSecret });
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' });
const binaryData = mock<IBinaryData>({ 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<IBinaryData>({ 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);
});
});
});

View File

@@ -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;
}

View File

@@ -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<string, BinaryData.Manager> = {};
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,

View File

@@ -70,4 +70,8 @@ export namespace BinaryData {
rename(oldFileId: string, newFileId: string): Promise<void>;
}
export type SigningPayload = {
id: string;
};
}