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..f39ea7c8631e3
--- /dev/null
+++ b/packages/frontend/@n8n/chat/src/__tests__/MessageWithButtons.spec.ts
@@ -0,0 +1,120 @@
+import { waitFor } from '@testing-library/vue';
+import { mount } from '@vue/test-utils';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+import MessageWithButtons from '../components/MessageWithButtons.vue';
+
+vi.mock('../components/MarkdownRenderer.vue', () => ({
+ default: {
+ name: 'MarkdownRenderer',
+ template: '
{{ text }}
',
+ props: ['text'],
+ },
+}));
+
+vi.mock('@n8n/chat/composables', () => ({
+ useOptions: () => ({
+ options: {
+ webhookUrl: 'https://webhook.example.com/webhook/123/chat',
+ },
+ }),
+}));
+
+const editorOrigin = 'http://localhost:5678';
+const webhookOrigin = 'https://webhook.example.com';
+
+const relativeUrlButtons = [
+ { 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(`${editorOrigin}/chat`),
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('does not render buttons whose links do not match the configured webhook origin', () => {
+ const wrapper = mount(MessageWithButtons, {
+ props: { text: 'Please confirm', buttons: relativeUrlButtons },
+ });
+
+ expect(wrapper.findAll('button')).toHaveLength(0);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('renders and fetches when the link is on the configured webhook origin', async () => {
+ vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);
+
+ const webhookButtons = [
+ {
+ text: 'Approve',
+ link: `${webhookOrigin}/webhook/123/approve`,
+ type: 'primary' as const,
+ },
+ ];
+
+ const wrapper = mount(MessageWithButtons, {
+ props: { text: 'Please approve', buttons: webhookButtons },
+ });
+
+ expect(wrapper.find('button').exists()).toBe(true);
+
+ await wrapper.find('button').trigger('click');
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledWith(`${webhookOrigin}/webhook/123/approve`));
+ });
+
+ it('does not render a button when the link points to an unrecognized origin', () => {
+ const externalButtons = [
+ { text: 'Go', link: 'https://broken-link.com/approve', type: 'primary' as const },
+ ];
+
+ const wrapper = mount(MessageWithButtons, {
+ props: { text: 'Click me', buttons: externalButtons },
+ });
+
+ expect(wrapper.find('button').exists()).toBe(false);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ 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 },
+ ];
+
+ const wrapper = mount(MessageWithButtons, {
+ props: { text: 'Click me', buttons: externalButtons },
+ });
+
+ expect(wrapper.find('button').exists()).toBe(false);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('renders only valid-origin buttons when the list contains mixed URLs', () => {
+ const mixedButtons = [
+ { text: 'Editor', link: '/api/confirm', type: 'primary' as const },
+ {
+ text: 'Webhook',
+ link: `${webhookOrigin}/webhook/123/approve`,
+ type: 'secondary' as const,
+ },
+ { text: 'Other', link: 'http://broken-url/approve', 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('Webhook');
+ });
+});
diff --git a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue
index c47a990cbe45a..ea1c07bcac055 100644
--- a/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue
+++ b/packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue
@@ -1,6 +1,8 @@