From 08f4b5283b5bee902dbcabc059fe11600898e925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= Date: Wed, 25 Feb 2026 14:56:52 +0100 Subject: [PATCH 1/6] fix: Improve chat message button handling --- .../src/__tests__/MessageWithButtons.spec.ts | 78 +++++++++++++++++++ .../src/components/MessageWithButtons.vue | 13 ++++ 2 files changed, 91 insertions(+) create mode 100644 packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts diff --git a/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts b/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts new file mode 100644 index 0000000000000..c9f2e36635a7d --- /dev/null +++ b/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import MessageWithButtons from '../components/MessageWithButtons.vue'; +import { waitFor } from '@testing-library/vue'; + +vi.mock('../components/MarkdownRenderer.vue', () => ({ + default: { + name: 'MarkdownRenderer', + template: '
{{ text }}
', + props: ['text'], + }, +})); + +const buttons = [ + { text: 'Confirm', link: '/api/confirm', type: 'primary' as const }, + { text: 'Cancel', link: '/api/cancel', type: 'secondary' as const }, +]; + +describe('MessageWithButtons', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + Object.defineProperty(window, 'location', { + value: new URL('http://localhost:5678/chat'), + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches when the link is on the same origin', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); + + const wrapper = mount(MessageWithButtons, { + props: { text: 'Please confirm', buttons }, + }); + + await wrapper.findAll('button')[0].trigger('click'); + + await waitFor(() => expect(fetch).toHaveBeenCalledWith('/api/confirm')); + }); + + it('does not fetch when the link points to a different origin', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); + + const externalButtons = [ + { text: 'Go', link: 'https://other-domain/approve', type: 'primary' as const }, + ]; + + const wrapper = mount(MessageWithButtons, { + props: { text: 'Click me', buttons: externalButtons }, + }); + + await wrapper.find('button').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not fetch for an absolute URL on a different domain even with same path', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); + + const externalButtons = [ + { text: 'Go', link: 'http://other-host:5678/api/confirm', type: 'primary' as const }, + ]; + + const wrapper = mount(MessageWithButtons, { + props: { text: 'Click me', buttons: externalButtons }, + }); + + await wrapper.find('button').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue index c47a990cbe45a..755b1228d3113 100644 --- a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue +++ b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue @@ -15,11 +15,24 @@ defineProps<{ const clickedButtonIndex = ref(null); +const isSameDomain = (link: string): boolean => { + try { + const url = new URL(link, window.location.href); + return url.origin === window.location.origin; + } catch { + return false; + } +}; + const onClick = async (link: string, index: number) => { if (clickedButtonIndex.value !== null) { return; } + if (!isSameDomain(link)) { + return; + } + const response = await fetch(link); if (response.ok) { clickedButtonIndex.value = index; From 114f4c969204c2d4e15158ec3b4b75f01bcb53a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= Date: Wed, 25 Feb 2026 15:13:34 +0100 Subject: [PATCH 2/6] update functionality --- .../src/__tests__/MessageWithButtons.spec.ts | 35 ++++++++++++------- .../src/components/MessageWithButtons.vue | 9 +++-- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts b/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts index c9f2e36635a7d..d666e14025706 100644 --- a/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts +++ b/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts @@ -30,21 +30,21 @@ describe('MessageWithButtons', () => { vi.restoreAllMocks(); }); - it('fetches when the link is on the same origin', async () => { + it('renders and fetches when the link is on the same origin', async () => { vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); const wrapper = mount(MessageWithButtons, { props: { text: 'Please confirm', buttons }, }); + expect(wrapper.findAll('button')).toHaveLength(2); + await wrapper.findAll('button')[0].trigger('click'); await waitFor(() => expect(fetch).toHaveBeenCalledWith('/api/confirm')); }); - it('does not fetch when the link points to a different origin', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); - + it('does not render a button when the link points to a different origin', () => { const externalButtons = [ { text: 'Go', link: 'https://other-domain/approve', type: 'primary' as const }, ]; @@ -53,15 +53,11 @@ describe('MessageWithButtons', () => { props: { text: 'Click me', buttons: externalButtons }, }); - await wrapper.find('button').trigger('click'); - await new Promise((r) => setTimeout(r, 0)); - + expect(wrapper.find('button').exists()).toBe(false); expect(fetch).not.toHaveBeenCalled(); }); - it('does not fetch for an absolute URL on a different domain even with same path', async () => { - vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); - + it('does not render a button for an absolute URL on a different host with the same port', () => { const externalButtons = [ { text: 'Go', link: 'http://other-host:5678/api/confirm', type: 'primary' as const }, ]; @@ -70,9 +66,22 @@ describe('MessageWithButtons', () => { props: { text: 'Click me', buttons: externalButtons }, }); - await wrapper.find('button').trigger('click'); - await new Promise((r) => setTimeout(r, 0)); - + expect(wrapper.find('button').exists()).toBe(false); expect(fetch).not.toHaveBeenCalled(); }); + + it('renders only same-origin buttons when the list contains mixed URLs', () => { + const mixedButtons = [ + { text: 'Safe', link: '/api/confirm', type: 'primary' as const }, + { text: 'Unsafe', link: 'https://evil.example.com/steal', type: 'secondary' as const }, + ]; + + const wrapper = mount(MessageWithButtons, { + props: { text: 'Choose', buttons: mixedButtons }, + }); + + const rendered = wrapper.findAll('button'); + expect(rendered).toHaveLength(1); + expect(rendered[0].text()).toBe('Safe'); + }); }); diff --git a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue index 755b1228d3113..6dc54b7334c50 100644 --- a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue +++ b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue @@ -29,10 +29,6 @@ const onClick = async (link: string, index: number) => { return; } - if (!isSameDomain(link)) { - return; - } - const response = await fetch(link); if (response.ok) { clickedButtonIndex.value = index; @@ -46,7 +42,10 @@ const onClick = async (link: string, index: number) => {