fix(editor): Avoid sanitizing output to search node data (#8126)

## Summary
In search feature, output sanitization was added to support `<mark` tag
in output panel to highlight searched text. This removes any html like
data in the input/output panel..
This PR removes sanitization while keeping text highlights..


## Related tickets and issues
https://community.n8n.io/t/n8n-output/33997
https://community.n8n.io/t/html-tags-in-editor-rendered/34240
https://github.com/n8n-io/n8n/issues/8081
https://linear.app/n8n/issue/ADO-1594/node-output-view-not-consistent
https://linear.app/n8n/issue/ADO-1597/bug-xml-display-issue


## Review / Merge checklist
- [X] PR title and summary are descriptive. **Remember, the title
automatically goes into the changelog. Use `(no-changelog)` otherwise.**
([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md))
- [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up
ticket created.
- [ ] Tests included.
> A bug is not considered fixed, unless a test is added to prevent it
from happening again.
   > A feature is not complete without tests.
This commit is contained in:
Mutasem Aldmour 2023-12-22 15:03:40 +01:00 committed by GitHub
parent e928210ccd
commit c83d9f45ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 279 additions and 75 deletions

View file

@ -471,7 +471,8 @@ describe('NDV', () => {
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
});
})
});
it('should show node name and version in settings', () => {
cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`);
@ -490,6 +491,34 @@ describe('NDV', () => {
ndv.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)');
ndv.actions.close();
});
it('Should render xml and html tags as strings and can search', () => {
cy.createFixtureWorkflow('Test_workflow_xml_output.json', `test`);
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Edit Fields');
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table');
ndv.getters.outputTableRow(1).should('include.text', '<?xml version="1.0" encoding="UTF-8"?> <library>');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.searchInput().filter(':focus').type('<lib');
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib')
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
ndv.getters.outputDisplayMode().find('label').eq(1).click();
ndv.getters.outputDataContainer().should('have.text', '[{"body": "<?xml version="1.0" encoding="UTF-8"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"}]');
ndv.getters.outputDataContainer().find('mark').should('have.text', '<lib')
ndv.getters.outputDisplayMode().find('label').eq(2).should('include.text', 'Schema');
ndv.getters.outputDisplayMode().find('label').eq(2).click({force: true});
ndv.getters.outputDataContainer().findChildByTestId('run-data-schema-item').find('> span').should('include.text', '<?xml version="1.0" encoding="UTF-8"?>');
});
it('should properly show node execution indicator', () => {
workflowPage.actions.addInitialNodeToCanvas('Code');
workflowPage.actions.openNode('Code');
@ -499,6 +528,7 @@ describe('NDV', () => {
ndv.getters.nodeExecuteButton().click();
ndv.getters.nodeRunSuccessIndicator().should('exist');
});
it('should properly show node execution indicator for multiple nodes', () => {
workflowPage.actions.addInitialNodeToCanvas('Code');
workflowPage.actions.openNode('Code');
@ -513,6 +543,7 @@ describe('NDV', () => {
workflowPage.actions.openNode('Code');
ndv.getters.nodeRunErrorIndicator().should('exist');
});
it('Should handle mismatched option attributes', () => {
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
// Add some attributes in Create operation
@ -521,6 +552,7 @@ describe('NDV', () => {
// Attributes should be empty after operation change
cy.getByTestId('parameter-item').contains('Currently no items exist').should('exist');
});
it('Should keep RLC values after operation change', () => {
const TEST_DOC_ID = '1111';
workflowPage.actions.addInitialNodeToCanvas('Google Sheets', { keepNdvOpen: true, action: 'Append row in sheet' });

View file

@ -0,0 +1,53 @@
{
"meta": {
"instanceId": "2d1cf27f75b18bb9e146336f791c37884f4fc7ddb97c2def27c0444d106778bf"
},
"nodes": [
{
"parameters": {},
"id": "8108d313-8b03-4aa4-963d-cd1c0fe8f85c",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
420,
220
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "body",
"stringValue": "<?xml version=\"1.0\" encoding=\"UTF-8\"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"
}
]
},
"options": {}
},
"id": "45888152-7c5f-4d88-9039-660c594da084",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
640,
220
]
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

@ -33,7 +33,9 @@
@update:selectedValue="selectedJsonPath = $event"
>
<template #renderNodeKey="{ node }">
<span
<TextWithHighlights
:content="getContent(node.key)"
:search="search"
data-target="mappable"
:data-value="getJsonParameterPath(node.path)"
:data-name="node.key"
@ -43,13 +45,18 @@
[$style.mappable]: mappingEnabled,
[$style.dragged]: draggingPath === node.path,
}"
v-html="highlightSearchTerm(node.key)"
/>
</template>
<template #renderNodeValue="{ node }">
<span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
<span
<TextWithHighlights
v-if="isNaN(node.index)"
:content="getContent(node.content)"
:search="search"
/>
<TextWithHighlights
v-else
:content="getContent(node.content)"
:search="search"
data-target="mappable"
:data-value="getJsonParameterPath(node.path)"
:data-name="getListItemName(node.path)"
@ -60,7 +67,6 @@
[$style.dragged]: draggingPath === node.path,
}"
class="ph-no-capture"
v-html="highlightSearchTerm(node.content)"
/>
</template>
</vue-json-pretty>
@ -76,7 +82,6 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from '@/components/Draggable.vue';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { isString } from '@/utils/typeGuards';
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
import { shorten } from '@/utils/typesUtils';
import type { INodeUi } from '@/Interface';
import { mapStores } from 'pinia';
@ -86,6 +91,7 @@ import { getMappedExpression } from '@/utils/mappingUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { nonExistingJsonPath } from '@/constants';
import { useExternalHooks } from '@/composables/useExternalHooks';
import TextWithHighlights from './TextWithHighlights.vue';
const RunDataJsonActions = defineAsyncComponent(
async () => import('@/components/RunDataJsonActions.vue'),
@ -98,6 +104,7 @@ export default defineComponent({
Draggable,
RunDataJsonActions,
MappingPill,
TextWithHighlights,
},
props: {
editMode: {
@ -202,9 +209,6 @@ export default defineComponent({
getListItemName(path: string): string {
return path.replace(/^(\["?\d"?]\.?)/g, '');
},
highlightSearchTerm(value: string): string {
return sanitizeHtml(highlightText(this.getContent(value), this.search));
},
},
});
</script>

View file

@ -1,10 +1,10 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { INodeUi, Schema } from '@/Interface';
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
import { checkExhaustive } from '@/utils/typeGuards';
import { shorten } from '@/utils/typesUtils';
import { getMappedExpression } from '@/utils/mappingUtils';
import TextWithHighlights from './TextWithHighlights.vue';
type Props = {
schema: Schema;
@ -30,12 +30,8 @@ const isFlat = computed(
props.schema.value.every((v) => !Array.isArray(v.value)),
);
const key = computed((): string | undefined => {
const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
});
const parentKey = computed((): string | undefined =>
sanitizeHtml(highlightText(props.parent.key, props.search)),
);
const schemaName = computed(() =>
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
);
@ -99,8 +95,17 @@ const getIconBySchemaType = (type: Schema['type']): string => {
data-target="mappable"
>
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
<span v-if="isSchemaParentTypeArray" v-html="parentKey" />
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
<TextWithHighlights
v-if="isSchemaParentTypeArray"
:content="props.parent?.key"
:search="props.search"
/>
<TextWithHighlights
v-if="key"
:class="{ [$style.arrayIndex]: isSchemaParentTypeArray }"
:content="key"
:search="props.search"
/>
</span>
</div>
<span v-if="text" :class="$style.text">{{ text }}</span>

View file

@ -52,7 +52,10 @@
[$style.draggingHeader]: isDragging,
}"
>
<span v-html="highlightSearchTerm(column || '')" />
<TextWithHighlights
:content="getValueToRender(column || '')"
:search="search"
/>
<div :class="$style.dragButton">
<font-awesome-icon icon="grip-vertical" />
</div>
@ -117,10 +120,11 @@
@mouseleave="onMouseLeaveCell"
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
>
<span
<TextWithHighlights
v-if="isSimple(data)"
:content="getValueToRender(data)"
:search="search"
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
v-html="highlightSearchTerm(data)"
/>
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
<template #label="{ label, path }">
@ -141,9 +145,10 @@
>
</template>
<template #value="{ value }">
<span
<TextWithHighlights
:content="getValueToRender(value)"
:search="search"
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
v-html="highlightSearchTerm(value)"
/>
</template>
</n8n-tree>
@ -162,7 +167,6 @@ import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { INodeUi, ITableData, NDVState } from '@/Interface';
import { shorten } from '@/utils/typesUtils';
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
import { getPairedItemId } from '@/utils/pairedItemUtils';
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from './Draggable.vue';
@ -171,6 +175,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import MappingPill from './MappingPill.vue';
import { getMappedExpression } from '@/utils/mappingUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
import TextWithHighlights from './TextWithHighlights.vue';
const MAX_COLUMNS_LIMIT = 40;
@ -178,7 +183,7 @@ type DraggableRef = InstanceType<typeof Draggable>;
export default defineComponent({
name: 'run-data-table',
components: { Draggable, MappingPill },
components: { Draggable, MappingPill, TextWithHighlights },
props: {
node: {
type: Object as PropType<INodeUi>,
@ -392,9 +397,6 @@ export default defineComponent({
}
return value;
},
highlightSearchTerm(value: string): string {
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
},
onDragStart() {
this.draggedColumn = true;
this.ndvStore.resetMappingTelemetry();

View file

@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { PropType } from 'vue';
import type { GenericValue } from 'n8n-workflow';
import { computed } from 'vue';
const props = defineProps({
content: {
type: [Object, String, Number] as PropType<GenericValue>,
},
search: {
type: String,
},
});
const splitTextBySearch = (
text = '',
search = '',
): Array<{ tag: 'span' | 'mark'; content: string }> => {
if (!search) {
return [
{
tag: 'span',
content: text,
},
];
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
const splitText = text.split(pattern);
return splitText.map((t) => ({ tag: pattern.test(t) ? 'mark' : 'span', content: t }));
};
const parts = computed(() => {
return props.search && typeof props.content === 'string'
? splitTextBySearch(props.content, props.search)
: [];
});
</script>
<template>
<span v-if="parts.length && typeof props.content === 'string'">
<template v-for="(part, index) in parts">
<mark v-if="part.tag === 'mark' && part.content" :key="`mark-${index}`">{{
part.content
}}</mark>
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
</template>
</span>
<span v-else>{{ props.content }}</span>
</template>

View file

@ -0,0 +1,103 @@
import { shallowMount } from '@vue/test-utils';
import TextWithHighlights from '@/components/TextWithHighlights.vue';
describe('TextWithHighlights', () => {
it('highlights the search text in the content', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: 'Test content',
search: 'Test',
},
});
expect(wrapper.html()).toContain('<mark>Test</mark>');
expect(wrapper.html()).toContain('<span> content</span>');
});
it('renders correctly when search is not set', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: 'Test content',
},
});
expect(wrapper.html()).toEqual('<span>Test content</span>');
expect(wrapper.html()).not.toContain('<mark>');
});
it('renders correctly numbers when search is not set', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: 1,
},
});
expect(wrapper.html()).toEqual('<span>1</span>');
expect(wrapper.html()).not.toContain('<mark>');
});
it('renders correctly objects when search is not set', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: { hello: 'world' },
},
});
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
expect(wrapper.html()).not.toContain('<mark>');
});
it('renders correctly objects ignoring search', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: { hello: 'world' },
search: 'yo',
},
});
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
expect(wrapper.html()).not.toContain('<mark>');
});
it('highlights the search text in middle of the content', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: 'Test content hello world',
search: 'con',
},
});
expect(wrapper.html()).toEqual(
'<span><span>Test </span><mark>con</mark><span>tent hello world</span></span>',
);
});
it('handles special regex characters in search correctly', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
content: 'Test content (hello) world',
search: '(hello)',
},
});
expect(wrapper.html()).toEqual(
'<span><span>Test content </span><mark>(hello)</mark><span> world</span></span>',
);
});
it('searches for special regex characters correctly', () => {
const wrapper = shallowMount(TextWithHighlights, {
props: {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
content: 'Test content ()^${}[] world',
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
search: '()^${}[]',
},
});
expect(wrapper.html()).toEqual(
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
'<span><span>Test content </span><mark>()^${}[]</mark><span> world</span></span>',
);
});
});

View file

@ -1,41 +0,0 @@
import { highlightText } from '@/utils/htmlUtils';
describe('highlightText', () => {
it('should return original text if search parameter is an empty string', () => {
const text = 'some text';
const result = highlightText(text);
expect(result).toBe(text);
});
it('should return original text if it is an empty string', () => {
const text = '';
const result = highlightText(text, 'search');
expect(result).toBe(text);
});
it('should escape special characters in the search string', () => {
const text = 'some text [example]';
const result = highlightText(text, '[example]');
expect(result).toBe('some text <mark class="highlight">[example]</mark>');
});
it('should escape other special characters in the search string', () => {
const text = 'phone number: +123-456-7890';
const result = highlightText(text, '+123-456-7890');
expect(result).toBe('phone number: <mark class="highlight">+123-456-7890</mark>');
});
it('should highlight occurrences of the search string in text', () => {
const text = 'example text example';
const result = highlightText(text, 'example');
expect(result).toBe(
'<mark class="highlight">example</mark> text <mark class="highlight">example</mark>',
);
});
it('should return original text if the search string is not found', () => {
const text = 'some text';
const result = highlightText(text, 'notfound');
expect(result).toBe(text);
});
});

View file

@ -63,9 +63,3 @@ export const getBannerRowHeight = async (): Promise<number> => {
}, 0);
});
};
export const highlightText = (text: string, search = ''): string => {
const pattern = search.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
const regex = new RegExp(`(${pattern})`, 'gi');
return search ? text?.replace(regex, '<mark class="highlight">$1</mark>') : text;
};