mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Render sanitized HTML content in toast messages (#12139)
This commit is contained in:
parent
ed359586c8
commit
0468945c99
|
@ -96,7 +96,6 @@ async function send() {
|
|||
message: Number(form.value.value) >= 8 ? i18n.baseText('prompts.npsSurvey.reviewUs') : '',
|
||||
type: 'success',
|
||||
duration: 15000,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
|
@ -167,7 +167,6 @@ function onDrop(newParamValue: string) {
|
|||
title: i18n.baseText('dataMapping.success.title'),
|
||||
message: i18n.baseText('dataMapping.success.moreInfo'),
|
||||
type: 'success',
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
|
||||
ndvStore.setMappingOnboarded();
|
||||
|
|
|
@ -76,7 +76,7 @@ export const useExecutionDebugging = () => {
|
|||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('nodeView.confirmMessage.debug.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('nodeView.confirmMessage.debug.cancelButtonText'),
|
||||
dangerouslyUseHTMLString: true,
|
||||
|
||||
customClass: 'matching-pinned-nodes-confirmation',
|
||||
},
|
||||
);
|
||||
|
|
|
@ -202,7 +202,6 @@ describe('usePushConnection()', () => {
|
|||
title: 'Problem in node ‘Last Node‘',
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
|
|
|
@ -397,7 +397,6 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||
message: runDataExecutedErrorMessage,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,55 +1,90 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { h, defineComponent } from 'vue';
|
||||
import { useToast } from './useToast';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { ElNotification as Notification } from 'element-plus';
|
||||
|
||||
vi.mock('element-plus', async () => {
|
||||
const original = await vi.importActual('element-plus');
|
||||
return {
|
||||
...original,
|
||||
ElNotification: vi.fn(),
|
||||
ElTooltip: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useToast', () => {
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
document.body.innerHTML = '<div id="app-grid"></div>';
|
||||
createTestingPinia();
|
||||
|
||||
toast = useToast();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should show a message', () => {
|
||||
it('should show a message', async () => {
|
||||
const messageData = { message: 'Test message', title: 'Test title' };
|
||||
toast.showMessage(messageData);
|
||||
|
||||
expect(Notification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Test message',
|
||||
title: 'Test title',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole('alert')).getByRole('heading', { level: 2 }),
|
||||
).toHaveTextContent('Test title');
|
||||
expect(screen.getByRole('alert')).toContainHTML('<p>Test message</p>');
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize message and title', () => {
|
||||
it('should sanitize message and title', async () => {
|
||||
const messageData = {
|
||||
message: '<script>alert("xss")</script>',
|
||||
title: '<script>alert("xss")</script>',
|
||||
};
|
||||
toast.showMessage(messageData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole('alert')).getByRole('heading', { level: 2 }),
|
||||
).toHaveTextContent('alert("xss")');
|
||||
expect(screen.getByRole('alert')).toContainHTML('<p>alert("xss")</p>');
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize but keep valid, allowed HTML tags', async () => {
|
||||
const messageData = {
|
||||
message:
|
||||
'<a data-action="reload">Refresh</a> to see the <strong>latest status</strong>.<br/> <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">More info</a> or go to the <a href="/settings/usage">Usage and plan</a> settings page.',
|
||||
title: '<strong>Title</strong>',
|
||||
};
|
||||
|
||||
toast.showMessage(messageData);
|
||||
|
||||
expect(Notification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'alert("xss")',
|
||||
title: 'alert("xss")',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole('alert')).getByRole('heading', { level: 2 }),
|
||||
).toHaveTextContent('Title');
|
||||
expect(
|
||||
within(screen.getByRole('alert')).getByRole('heading', { level: 2 }).querySelectorAll('*'),
|
||||
).toHaveLength(0);
|
||||
expect(screen.getByRole('alert')).toContainHTML(
|
||||
'<a data-action="reload">Refresh</a> to see the <strong>latest status</strong>.<br /> <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">More info</a> or go to the <a href="/settings/usage">Usage and plan</a> settings page.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component as message, sanitized as well', async () => {
|
||||
const messageData = {
|
||||
message: h(
|
||||
defineComponent({
|
||||
template: '<p>Test <strong>content</strong><script>alert("xss")</script></p>',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
toast.showMessage(messageData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole('alert')).queryByRole('heading', { level: 2 }),
|
||||
).toHaveTextContent('');
|
||||
expect(
|
||||
within(screen.getByRole('alert')).getByRole('heading', { level: 2 }).querySelectorAll('*'),
|
||||
).toHaveLength(0);
|
||||
expect(screen.getByRole('alert')).toContainHTML('<p>Test <strong>content</strong></p>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,7 +33,7 @@ export function useToast() {
|
|||
const canvasStore = useCanvasStore();
|
||||
|
||||
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
||||
dangerouslyUseHTMLString: false,
|
||||
dangerouslyUseHTMLString: true,
|
||||
position: 'bottom-right',
|
||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||
offset: settingsStore.isAiAssistantEnabled || workflowsStore.isChatPanelOpen ? 64 : 0,
|
||||
|
@ -82,7 +82,6 @@ export function useToast() {
|
|||
customClass?: string;
|
||||
closeOnClick?: boolean;
|
||||
type?: MessageBoxState['type'];
|
||||
dangerouslyUseHTMLString?: boolean;
|
||||
}) {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let notification: NotificationHandle;
|
||||
|
@ -107,7 +106,6 @@ export function useToast() {
|
|||
duration: config.duration,
|
||||
customClass: config.customClass,
|
||||
type: config.type,
|
||||
dangerouslyUseHTMLString: config.dangerouslyUseHTMLString ?? true,
|
||||
});
|
||||
|
||||
return notification;
|
||||
|
@ -145,7 +143,6 @@ export function useToast() {
|
|||
${collapsableDetails(error)}`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
@ -165,12 +162,6 @@ export function useToast() {
|
|||
});
|
||||
}
|
||||
|
||||
function showAlert(config: NotificationOptions): NotificationHandle {
|
||||
return Notification({
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
function causedByCredential(message: string | undefined) {
|
||||
if (!message || typeof message !== 'string') return false;
|
||||
|
||||
|
@ -209,7 +200,6 @@ export function useToast() {
|
|||
showMessage,
|
||||
showToast,
|
||||
showError,
|
||||
showAlert,
|
||||
clearAllStickyNotifications,
|
||||
showNotificationForViews,
|
||||
};
|
||||
|
|
|
@ -882,7 +882,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||
}),
|
||||
i18n.baseText('workflows.concurrentChanges.confirmMessage.title'),
|
||||
{
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: i18n.baseText(
|
||||
'workflows.concurrentChanges.confirmMessage.confirmButtonText',
|
||||
),
|
||||
|
|
|
@ -278,7 +278,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
message: i18n.baseText('startupError.message'),
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
|
||||
throw e;
|
||||
|
|
|
@ -18,8 +18,8 @@ export function sanitizeHtml(dirtyHtml: string) {
|
|||
}
|
||||
|
||||
if (ALLOWED_HTML_ATTRIBUTES.includes(name) || name.startsWith('data-')) {
|
||||
// href is allowed but we need to sanitize certain protocols
|
||||
if (name === 'href' && !value.match(/^https?:\/\//gm)) {
|
||||
// href is allowed but we allow only https and relative URLs
|
||||
if (name === 'href' && !value.match(/^https?:\/\//gm) && !value.startsWith('/')) {
|
||||
return '';
|
||||
}
|
||||
return `${name}="${escapeAttrValue(value)}"`;
|
||||
|
|
|
@ -587,7 +587,6 @@ async function onClipboardPaste(plainTextData: string): Promise<void> {
|
|||
cancelButtonText: i18n.baseText(
|
||||
'nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText',
|
||||
),
|
||||
dangerouslyUseHTMLString: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -1368,7 +1367,6 @@ function checkIfEditingIsAllowed(): boolean {
|
|||
: 'readOnly.showMessage.executions.message',
|
||||
),
|
||||
type: 'info',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}) as unknown as { visible: boolean };
|
||||
|
||||
return false;
|
||||
|
|
|
@ -825,7 +825,7 @@ export default defineComponent({
|
|||
: 'readOnly.showMessage.executions.message',
|
||||
),
|
||||
type: 'info',
|
||||
dangerouslyUseHTMLString: true,
|
||||
|
||||
onClose: () => {
|
||||
this.readOnlyNotification = null;
|
||||
},
|
||||
|
@ -934,7 +934,6 @@ export default defineComponent({
|
|||
// Close the creator panel if user clicked on the link
|
||||
if (this.createNodeActive) notice.close();
|
||||
}, 0),
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
},
|
||||
async clearExecutionData() {
|
||||
|
@ -1827,7 +1826,6 @@ export default defineComponent({
|
|||
cancelButtonText: this.i18n.baseText(
|
||||
'nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText',
|
||||
),
|
||||
dangerouslyUseHTMLString: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue