diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index c0bc13020e..6f595f9611 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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 { diff --git a/packages/@n8n/api-types/src/schemas/binaryData.schema.ts b/packages/@n8n/api-types/src/schemas/binaryData.schema.ts new file mode 100644 index 0000000000..5ff3ae88c2 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/binaryData.schema.ts @@ -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', +]; 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 b0170ba523..16d1f04e62 100644 --- a/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/binary-data.controller.test.ts @@ -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); @@ -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'); @@ -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', @@ -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(); binaryDataService.getAsStream.mockResolvedValue(stream); diff --git a/packages/cli/src/controllers/binary-data.controller.ts b/packages/cli/src/controllers/binary-data.controller.ts index c0cb2dbff6..6ba49f8793 100644 --- a/packages/cli/src/controllers/binary-data.controller.ts +++ b/packages/cli/src/controllers/binary-data.controller.ts @@ -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'; @@ -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) { diff --git a/packages/frontend/editor-ui/src/components/RunData.test.ts b/packages/frontend/editor-ui/src/components/RunData.test.ts index bd28fd1b4b..7578c784ea 100644 --- a/packages/frontend/editor-ui/src/components/RunData.test.ts +++ b/packages/frontend/editor-ui/src/components/RunData.test.ts @@ -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: {}, @@ -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(); diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index 398fc0a825..5e2859db80 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -1,4 +1,5 @@