Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@n8n/api-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { createHeartbeatMessage, heartbeatMessageSchema } from './push/heartbeat
export type { SendWorkerStatusMessage } from './push/worker';

export type { BannerName } from './schemas/bannerName.schema';
export { ViewableMimeTypes } from './schemas/binaryData.schema';
export { passwordSchema } from './schemas/password.schema';

export type {
Expand Down
32 changes: 32 additions & 0 deletions packages/@n8n/api-types/src/schemas/binaryData.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* List of MIME types that are considered safe to be viewed directly in a browser.
*
* Explicitly excluded from this list:
* - 'text/html': Excluded due to high XSS risks, as HTML can execute arbitrary JavaScript
* - 'image/svg+xml': Excluded because SVG can contain embedded JavaScript that might execute in certain contexts
* - 'application/pdf': Excluded due to potential arbitrary code-execution vulnerabilities in PDF rendering engines
*/
export const ViewableMimeTypes = [
'application/json',

'audio/mpeg',
'audio/ogg',
'audio/wav',

'image/bmp',
'image/gif',
'image/jpeg',
'image/jpg',
'image/png',
'image/tiff',
'image/webp',

'text/css',
'text/csv',
'text/markdown',
'text/plain',

'video/mp4',
'video/ogg',
'video/webm',
];
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ describe('BinaryDataController', () => {
});

it('should return 404 if file is not found', async () => {
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
const query = {
id: 'filesystem:123',
action: 'view',
mimeType: 'image/jpeg',
} as BinaryDataQueryDto;
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));

await controller.get(request, response, query);
Expand All @@ -60,7 +64,7 @@ describe('BinaryDataController', () => {

await controller.get(request, response, query);

expect(binaryDataService.getMetadata).not.toHaveBeenCalled();
expect(binaryDataService.getMetadata).toHaveBeenCalledWith(query.id);
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Length');
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Disposition');
Expand Down Expand Up @@ -101,7 +105,7 @@ describe('BinaryDataController', () => {
);
});

it('should set Content-Security-Policy for HTML in view mode', async () => {
it('should not allow viewing of html files', async () => {
const query = {
id: 'filesystem:123',
action: 'view',
Expand All @@ -113,26 +117,32 @@ describe('BinaryDataController', () => {

await controller.get(request, response, query);

expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
expect(response.status).toHaveBeenCalledWith(400);
expect(response.setHeader).not.toHaveBeenCalled();
});

it('should not set Content-Security-Policy for HTML in download mode', async () => {
it('should allow viewing of jpeg files, and handle mime-type casing', async () => {
const query = {
id: 'filesystem:123',
action: 'download',
fileName: 'test.html',
mimeType: 'text/html',
action: 'view',
fileName: 'test.jpg',
mimeType: 'image/Jpeg',
} as BinaryDataQueryDto;

binaryDataService.getAsStream.mockResolvedValue(mock());

await controller.get(request, response, query);

expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
expect(response.status).not.toHaveBeenCalledWith(400);
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', query.mimeType);
});

it('should return the file stream on success', async () => {
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
const query = {
id: 'filesystem:123',
action: 'view',
mimeType: 'image/jpeg',
} as BinaryDataQueryDto;

const stream = mock<Readable>();
binaryDataService.getAsStream.mockResolvedValue(stream);
Expand Down
23 changes: 10 additions & 13 deletions packages/cli/src/controllers/binary-data.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types';
import { BinaryDataQueryDto, BinaryDataSignedQueryDto, ViewableMimeTypes } from '@n8n/api-types';
import { Request, Response } from 'express';
import { JsonWebTokenError } from 'jsonwebtoken';
import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
Expand Down Expand Up @@ -64,22 +64,19 @@ export class BinaryDataController {
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 {}
try {
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
fileName = metadata.fileName ?? fileName;
mimeType = metadata.mimeType ?? mimeType;
res.setHeader('Content-Length', metadata.fileSize);
} catch {}

if (action === 'view' && (!mimeType || !ViewableMimeTypes.includes(mimeType.toLowerCase()))) {
throw new BadRequestError('Content not viewable');
}

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) {
Expand Down
7 changes: 3 additions & 4 deletions packages/frontend/editor-ui/src/components/RunData.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ViewableMimeTypes } from '@n8n/api-types';
import { useStorage } from '@/composables/useStorage';
import { saveAs } from 'file-saver';
import type {
Expand Down Expand Up @@ -1182,10 +1183,8 @@ function closeBinaryDataDisplay() {
}

function isViewable(index: number, key: string | number): boolean {
const { fileType } = binaryData.value[index][key];
return (
!!fileType && ['image', 'audio', 'video', 'text', 'json', 'pdf', 'html'].includes(fileType)
);
const { mimeType } = binaryData.value[index][key];
return ViewableMimeTypes.includes(mimeType);
}

function isDownloadable(index: number, key: string | number): boolean {
Expand Down
Loading