Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 27 additions & 2 deletions packages/frontend/editor-ui/src/components/RunData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ describe('RunData', () => {
expect(getByText('Json data 1')).toBeInTheDocument();
});

it('should render view and download buttons for PDFs', async () => {
const { getByTestId } = render({
it('should render only download buttons for PDFs', async () => {
const { getByTestId, queryByTestId } = render({
defaultRunItems: [
{
json: {},
Expand All @@ -135,6 +135,31 @@ describe('RunData', () => {
displayMode: 'binary',
});

await waitFor(() => {
expect(queryByTestId('ndv-view-binary-data')).not.toBeInTheDocument();
expect(getByTestId('ndv-download-binary-data')).toBeInTheDocument();
expect(getByTestId('ndv-binary-data_0')).toBeInTheDocument();
});
});

it('should render view and download buttons for JPEGs', async () => {
const { getByTestId } = render({
defaultRunItems: [
{
json: {},
binary: {
data: {
fileName: 'test.jpg',
fileType: 'image',
mimeType: 'image/jpeg',
data: '',
},
},
},
],
displayMode: 'binary',
});

await waitFor(() => {
expect(getByTestId('ndv-view-binary-data')).toBeInTheDocument();
expect(getByTestId('ndv-download-binary-data')).toBeInTheDocument();
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