mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
fix(editor): Restrict what binary-data types can be viewed in the UI (#14685)
This commit is contained in:
committed by
GitHub
parent
68a87619af
commit
11a36b758d
@@ -12,6 +12,7 @@ export { createHeartbeatMessage, heartbeatMessageSchema } from './push/heartbeat
|
|||||||
export type { SendWorkerStatusMessage } from './push/worker';
|
export type { SendWorkerStatusMessage } from './push/worker';
|
||||||
|
|
||||||
export type { BannerName } from './schemas/bannerName.schema';
|
export type { BannerName } from './schemas/bannerName.schema';
|
||||||
|
export { ViewableMimeTypes } from './schemas/binaryData.schema';
|
||||||
export { passwordSchema } from './schemas/password.schema';
|
export { passwordSchema } from './schemas/password.schema';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
32
packages/@n8n/api-types/src/schemas/binaryData.schema.ts
Normal file
32
packages/@n8n/api-types/src/schemas/binaryData.schema.ts
Normal file
@@ -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',
|
||||||
|
];
|
||||||
@@ -39,7 +39,11 @@ describe('BinaryDataController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if file is not found', async () => {
|
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'));
|
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));
|
||||||
|
|
||||||
await controller.get(request, response, query);
|
await controller.get(request, response, query);
|
||||||
@@ -60,7 +64,7 @@ describe('BinaryDataController', () => {
|
|||||||
|
|
||||||
await controller.get(request, response, query);
|
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).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
||||||
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Length');
|
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Length');
|
||||||
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Disposition');
|
expect(response.setHeader).not.toHaveBeenCalledWith('Content-Disposition');
|
||||||
@@ -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 = {
|
const query = {
|
||||||
id: 'filesystem:123',
|
id: 'filesystem:123',
|
||||||
action: 'view',
|
action: 'view',
|
||||||
@@ -113,26 +117,32 @@ describe('BinaryDataController', () => {
|
|||||||
|
|
||||||
await controller.get(request, response, query);
|
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 = {
|
const query = {
|
||||||
id: 'filesystem:123',
|
id: 'filesystem:123',
|
||||||
action: 'download',
|
action: 'view',
|
||||||
fileName: 'test.html',
|
fileName: 'test.jpg',
|
||||||
mimeType: 'text/html',
|
mimeType: 'image/Jpeg',
|
||||||
} as BinaryDataQueryDto;
|
} as BinaryDataQueryDto;
|
||||||
|
|
||||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||||
|
|
||||||
await controller.get(request, response, query);
|
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 () => {
|
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>();
|
const stream = mock<Readable>();
|
||||||
binaryDataService.getAsStream.mockResolvedValue(stream);
|
binaryDataService.getAsStream.mockResolvedValue(stream);
|
||||||
|
|||||||
@@ -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 { Request, Response } from 'express';
|
||||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||||
import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
|
import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
|
||||||
@@ -64,22 +64,19 @@ export class BinaryDataController {
|
|||||||
fileName?: string,
|
fileName?: string,
|
||||||
mimeType?: string,
|
mimeType?: string,
|
||||||
) {
|
) {
|
||||||
if (!fileName || !mimeType) {
|
try {
|
||||||
try {
|
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
|
||||||
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
|
fileName = metadata.fileName ?? fileName;
|
||||||
fileName = metadata.fileName;
|
mimeType = metadata.mimeType ?? mimeType;
|
||||||
mimeType = metadata.mimeType;
|
res.setHeader('Content-Length', metadata.fileSize);
|
||||||
res.setHeader('Content-Length', metadata.fileSize);
|
} catch {}
|
||||||
} catch {}
|
|
||||||
|
if (action === 'view' && (!mimeType || !ViewableMimeTypes.includes(mimeType.toLowerCase()))) {
|
||||||
|
throw new BadRequestError('Content not viewable');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType) {
|
if (mimeType) {
|
||||||
res.setHeader('Content-Type', 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) {
|
if (action === 'download' && fileName) {
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ describe('RunData', () => {
|
|||||||
expect(getByText('Json data 1')).toBeInTheDocument();
|
expect(getByText('Json data 1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render view and download buttons for PDFs', async () => {
|
it('should render only download buttons for PDFs', async () => {
|
||||||
const { getByTestId } = render({
|
const { getByTestId, queryByTestId } = render({
|
||||||
defaultRunItems: [
|
defaultRunItems: [
|
||||||
{
|
{
|
||||||
json: {},
|
json: {},
|
||||||
@@ -135,6 +135,31 @@ describe('RunData', () => {
|
|||||||
displayMode: 'binary',
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(getByTestId('ndv-view-binary-data')).toBeInTheDocument();
|
expect(getByTestId('ndv-view-binary-data')).toBeInTheDocument();
|
||||||
expect(getByTestId('ndv-download-binary-data')).toBeInTheDocument();
|
expect(getByTestId('ndv-download-binary-data')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ViewableMimeTypes } from '@n8n/api-types';
|
||||||
import { useStorage } from '@/composables/useStorage';
|
import { useStorage } from '@/composables/useStorage';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import type {
|
import type {
|
||||||
@@ -1182,10 +1183,8 @@ function closeBinaryDataDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isViewable(index: number, key: string | number): boolean {
|
function isViewable(index: number, key: string | number): boolean {
|
||||||
const { fileType } = binaryData.value[index][key];
|
const { mimeType } = binaryData.value[index][key];
|
||||||
return (
|
return ViewableMimeTypes.includes(mimeType);
|
||||||
!!fileType && ['image', 'audio', 'video', 'text', 'json', 'pdf', 'html'].includes(fileType)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDownloadable(index: number, key: string | number): boolean {
|
function isDownloadable(index: number, key: string | number): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user