fix(editor): Sanitize HTML binary-data before rendering in the UI (#7400)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-10-11 12:09:19 +02:00 committed by GitHub
parent 47e8953ec9
commit 2b075bfc2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 31 additions and 21 deletions

View file

@ -7,16 +7,17 @@
<source :src="embedSource" :type="binaryData.mimeType" /> <source :src="embedSource" :type="binaryData.mimeType" />
{{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }} {{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</video> </video>
<audio v-if="binaryData.fileType === 'audio'" controls autoplay> <audio v-else-if="binaryData.fileType === 'audio'" controls autoplay>
<source :src="embedSource" :type="binaryData.mimeType" /> <source :src="embedSource" :type="binaryData.mimeType" />
{{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }} {{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</audio> </audio>
<vue-json-pretty <vue-json-pretty
v-else-if="binaryData.fileType === 'json'" v-else-if="binaryData.fileType === 'json'"
:data="jsonData" :data="data"
:deep="3" :deep="3"
:showLength="true" :showLength="true"
/> />
<run-data-html v-else-if="binaryData.fileType === 'html'" :inputHtml="data" />
<embed v-else :src="embedSource" class="binary-data" :class="embedClass()" /> <embed v-else :src="embedSource" class="binary-data" :class="embedClass()" />
</span> </span>
</span> </span>
@ -30,11 +31,13 @@ import { jsonParse } from 'n8n-workflow';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import VueJsonPretty from 'vue-json-pretty'; import VueJsonPretty from 'vue-json-pretty';
import { useWorkflowsStore } from '@/stores'; import { useWorkflowsStore } from '@/stores';
import RunDataHtml from '@/components/RunDataHtml.vue';
export default defineComponent({ export default defineComponent({
name: 'BinaryDataDisplayEmbed', name: 'BinaryDataDisplayEmbed',
components: { components: {
VueJsonPretty, VueJsonPretty,
RunDataHtml,
}, },
props: { props: {
binaryData: { binaryData: {
@ -47,27 +50,29 @@ export default defineComponent({
isLoading: true, isLoading: true,
embedSource: '', embedSource: '',
error: false, error: false,
jsonData: '', data: '',
}; };
}, },
computed: { computed: {
...mapStores(useWorkflowsStore), ...mapStores(useWorkflowsStore),
}, },
async mounted() { async mounted() {
const { id, data, fileName, fileType, mimeType } = (this.binaryData || {}) as IBinaryData; const { id, data, fileName, fileType, mimeType } = this.binaryData;
const isJSONData = fileType === 'json'; const isJSONData = fileType === 'json';
const isHTMLData = fileType === 'html';
if (!id) { if (!id) {
if (isJSONData) { if (isJSONData || isHTMLData) {
this.jsonData = jsonParse(atob(data)); this.data = jsonParse(atob(data));
} else { } else {
this.embedSource = 'data:' + mimeType + ';base64,' + data; this.embedSource = 'data:' + mimeType + ';base64,' + data;
} }
} else { } else {
try { try {
const binaryUrl = this.workflowsStore.getBinaryUrl(id, 'view', fileName, mimeType); const binaryUrl = this.workflowsStore.getBinaryUrl(id, 'view', fileName, mimeType);
if (isJSONData) { if (isJSONData || isHTMLData) {
this.jsonData = await (await fetch(binaryUrl)).json(); const fetchedData = await fetch(binaryUrl, { credentials: 'include' });
this.data = await (isJSONData ? fetchedData.json() : fetchedData.text());
} else { } else {
this.embedSource = binaryUrl; this.embedSource = binaryUrl;
} }
@ -80,7 +85,7 @@ export default defineComponent({
}, },
methods: { methods: {
embedClass(): string[] { embedClass(): string[] {
const { fileType } = (this.binaryData || {}) as IBinaryData; const { fileType } = this.binaryData;
return [fileType ?? 'other']; return [fileType ?? 'other'];
}, },
}, },

View file

@ -347,7 +347,7 @@
</Suspense> </Suspense>
<Suspense v-else-if="hasNodeRun && isPaneTypeOutput && displayMode === 'html'"> <Suspense v-else-if="hasNodeRun && isPaneTypeOutput && displayMode === 'html'">
<run-data-html :inputData="inputData" /> <run-data-html :inputHtml="inputData[0].json.html" />
</Suspense> </Suspense>
<Suspense v-else-if="hasNodeRun && isSchemaView"> <Suspense v-else-if="hasNodeRun && isSchemaView">
@ -1320,7 +1320,9 @@ export default defineComponent({
}, },
isViewable(index: number, key: string): boolean { isViewable(index: number, key: string): boolean {
const { fileType } = this.binaryData[index][key]; const { fileType } = this.binaryData[index][key];
return !!fileType && ['image', 'audio', 'video', 'text', 'json', 'pdf'].includes(fileType); return (
!!fileType && ['image', 'audio', 'video', 'text', 'json', 'pdf', 'html'].includes(fileType)
);
}, },
isDownloadable(index: number, key: string): boolean { isDownloadable(index: number, key: string): boolean {
const { mimeType, fileName } = this.binaryData[index][key]; const { mimeType, fileName } = this.binaryData[index][key];

View file

@ -1,11 +1,10 @@
<template> <template>
<iframe class="__html-display" :srcdoc="html" /> <iframe class="__html-display" :srcdoc="sanitizedHtml" />
</template> </template>
<script lang="ts"> <script lang="ts">
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import sanitizeHtml, { defaults, type IOptions as SanitizeOptions } from 'sanitize-html'; import sanitizeHtml, { defaults, type IOptions as SanitizeOptions } from 'sanitize-html';
import type { INodeExecutionData } from 'n8n-workflow';
const sanitizeOptions: SanitizeOptions = { const sanitizeOptions: SanitizeOptions = {
allowVulnerableTags: false, allowVulnerableTags: false,
@ -24,14 +23,13 @@ const sanitizeOptions: SanitizeOptions = {
export default { export default {
name: 'RunDataHtml', name: 'RunDataHtml',
props: { props: {
inputData: { inputHtml: {
type: Array as PropType<INodeExecutionData[]>, type: String as PropType<string>,
}, },
}, },
computed: { computed: {
html() { sanitizedHtml() {
const markup = (this.inputData?.[0].json.html as string) ?? ''; return sanitizeHtml(this.inputHtml, sanitizeOptions);
return sanitizeHtml(markup, sanitizeOptions);
}, },
}, },
}; };

View file

@ -102,7 +102,7 @@ describe('Execute Spreadsheet File Node', () => {
binary: { binary: {
data: { data: {
mimeType: 'text/html', mimeType: 'text/html',
fileType: 'text', fileType: 'html',
fileExtension: 'html', fileExtension: 'html',
data: 'PGh0bWw+PGhlYWQ+PG1ldGEgY2hhcnNldD0idXRmLTgiLz48dGl0bGU+U2hlZXRKUyBUYWJsZSBFeHBvcnQ8L3RpdGxlPjwvaGVhZD48Ym9keT48dGFibGU+PHRyPjx0ZCBkYXRhLXQ9InMiIGRhdGEtdj0iQSIgaWQ9InNqcy1BMSI+QTwvdGQ+PHRkIGRhdGEtdD0icyIgZGF0YS12PSJCIiBpZD0ic2pzLUIxIj5CPC90ZD48dGQgZGF0YS10PSJzIiBkYXRhLXY9IkMiIGlkPSJzanMtQzEiPkM8L3RkPjwvdHI+PHRyPjx0ZCBkYXRhLXQ9Im4iIGRhdGEtdj0iMSIgaWQ9InNqcy1BMiI+MTwvdGQ+PHRkIGRhdGEtdD0ibiIgZGF0YS12PSIyIiBpZD0ic2pzLUIyIj4yPC90ZD48dGQgZGF0YS10PSJuIiBkYXRhLXY9IjMiIGlkPSJzanMtQzIiPjM8L3RkPjwvdHI+PHRyPjx0ZCBkYXRhLXQ9Im4iIGRhdGEtdj0iNCIgaWQ9InNqcy1BMyI+NDwvdGQ+PHRkIGRhdGEtdD0ibiIgZGF0YS12PSI1IiBpZD0ic2pzLUIzIj41PC90ZD48dGQgZGF0YS10PSJuIiBkYXRhLXY9IjYiIGlkPSJzanMtQzMiPjY8L3RkPjwvdHI+PC90YWJsZT48L2JvZHk+PC9odG1sPg==', data: 'PGh0bWw+PGhlYWQ+PG1ldGEgY2hhcnNldD0idXRmLTgiLz48dGl0bGU+U2hlZXRKUyBUYWJsZSBFeHBvcnQ8L3RpdGxlPjwvaGVhZD48Ym9keT48dGFibGU+PHRyPjx0ZCBkYXRhLXQ9InMiIGRhdGEtdj0iQSIgaWQ9InNqcy1BMSI+QTwvdGQ+PHRkIGRhdGEtdD0icyIgZGF0YS12PSJCIiBpZD0ic2pzLUIxIj5CPC90ZD48dGQgZGF0YS10PSJzIiBkYXRhLXY9IkMiIGlkPSJzanMtQzEiPkM8L3RkPjwvdHI+PHRyPjx0ZCBkYXRhLXQ9Im4iIGRhdGEtdj0iMSIgaWQ9InNqcy1BMiI+MTwvdGQ+PHRkIGRhdGEtdD0ibiIgZGF0YS12PSIyIiBpZD0ic2pzLUIyIj4yPC90ZD48dGQgZGF0YS10PSJuIiBkYXRhLXY9IjMiIGlkPSJzanMtQzIiPjM8L3RkPjwvdHI+PHRyPjx0ZCBkYXRhLXQ9Im4iIGRhdGEtdj0iNCIgaWQ9InNqcy1BMyI+NDwvdGQ+PHRkIGRhdGEtdD0ibiIgZGF0YS12PSI1IiBpZD0ic2pzLUIzIj41PC90ZD48dGQgZGF0YS10PSJuIiBkYXRhLXY9IjYiIGlkPSJzanMtQzMiPjY8L3RkPjwvdHI+PC90YWJsZT48L2JvZHk+PC9odG1sPg==',
fileName: 'spreadsheet.html', fileName: 'spreadsheet.html',

View file

@ -35,7 +35,7 @@ export type IAllExecuteFunctions =
| ITriggerFunctions | ITriggerFunctions
| IWebhookFunctions; | IWebhookFunctions;
export type BinaryFileType = 'text' | 'json' | 'image' | 'audio' | 'video' | 'pdf'; export type BinaryFileType = 'text' | 'json' | 'image' | 'audio' | 'video' | 'pdf' | 'html';
export interface IBinaryData { export interface IBinaryData {
[key: string]: string | undefined; [key: string]: string | undefined;
data: string; data: string;

View file

@ -112,6 +112,7 @@ export const sleep = async (ms: number): Promise<void> =>
export function fileTypeFromMimeType(mimeType: string): BinaryFileType | undefined { export function fileTypeFromMimeType(mimeType: string): BinaryFileType | undefined {
if (mimeType.startsWith('application/json')) return 'json'; if (mimeType.startsWith('application/json')) return 'json';
if (mimeType.startsWith('text/html')) return 'html';
if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video'; if (mimeType.startsWith('video/')) return 'video';

View file

@ -196,6 +196,10 @@ describe('fileTypeFromMimeType', () => {
expect(fileTypeFromMimeType('application/json')).toEqual('json'); expect(fileTypeFromMimeType('application/json')).toEqual('json');
}); });
it('should recognize html', () => {
expect(fileTypeFromMimeType('text/html')).toEqual('html');
});
it('should recognize image', () => { it('should recognize image', () => {
expect(fileTypeFromMimeType('image/jpeg')).toEqual('image'); expect(fileTypeFromMimeType('image/jpeg')).toEqual('image');
expect(fileTypeFromMimeType('image/png')).toEqual('image'); expect(fileTypeFromMimeType('image/png')).toEqual('image');
@ -219,7 +223,7 @@ describe('fileTypeFromMimeType', () => {
it('should recognize text', () => { it('should recognize text', () => {
expect(fileTypeFromMimeType('text/plain')).toEqual('text'); expect(fileTypeFromMimeType('text/plain')).toEqual('text');
expect(fileTypeFromMimeType('text/css')).toEqual('text'); expect(fileTypeFromMimeType('text/css')).toEqual('text');
expect(fileTypeFromMimeType('text/html')).toEqual('text'); expect(fileTypeFromMimeType('text/html')).not.toEqual('text');
expect(fileTypeFromMimeType('text/javascript')).toEqual('text'); expect(fileTypeFromMimeType('text/javascript')).toEqual('text');
expect(fileTypeFromMimeType('application/javascript')).toEqual('text'); expect(fileTypeFromMimeType('application/javascript')).toEqual('text');
}); });