diff --git a/packages/@n8n/mcp-browser-extension/manifest.json b/packages/@n8n/mcp-browser-extension/manifest.json index ab33a686016..80b73a61b57 100644 --- a/packages/@n8n/mcp-browser-extension/manifest.json +++ b/packages/@n8n/mcp-browser-extension/manifest.json @@ -2,10 +2,23 @@ "manifest_version": 3, "name": "n8n Browser Use", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvRoWEdJjhgP9qs6R1jcemywrw+I91EJZtYur5C97hjjUc5nPao4jSv1qXFksdKuMddb9IEvzBElr5EYsXSaiVqdbRl8Gge0xYV1gGga653T2d9BuXL7NKv/wZxJ2i/coHSjhhIULQUBAVwu0JFMbHY5T8LfqrzBljuY7u1Xa7jmLmx0QrsoKLbGUoOBVZz4ztEGKEQHEelgg+ph2LrcYJczMBZ80PaHQAaWrvbCYF4vnZLd++Svy70ZCt7gr93L8BXHc8j1c3VojQTk+Uvqhm/4nZdYHlEmruQkd0pE+zTyegbcDlw0oc+6sLbsc0CqmJz8zH0OvSTfSVK7h6LNwbQIDAQAB", - "version": "0.0.3", + "version": "0.0.4", "description": "Let n8n AI set up credentials, fill out forms, and automate anything in your browser", "permissions": ["debugger", "activeTab", "tabs", "storage", "webNavigation"], "host_permissions": [""], + "web_accessible_resources": [ + { + "resources": ["connect.html"], + "matches": [ + "https://*.app.n8n.cloud/*", + "https://*.stage-app.n8n.cloud/*", + "http://localhost/*", + "https://localhost/*", + "http://127.0.0.1/*", + "https://127.0.0.1/*" + ] + } + ], "background": { "service_worker": "background.mjs", "type": "module" diff --git a/packages/@n8n/mcp-browser-extension/package.json b/packages/@n8n/mcp-browser-extension/package.json index 76da5f349ec..39c8faa6ffd 100644 --- a/packages/@n8n/mcp-browser-extension/package.json +++ b/packages/@n8n/mcp-browser-extension/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/mcp-browser-extension", - "version": "0.0.3", + "version": "0.0.4", "private": true, "description": "Chrome extension that lets n8n AI control browser tabs via CDP", "scripts": { diff --git a/packages/@n8n/mcp-browser-extension/src/background.test.ts b/packages/@n8n/mcp-browser-extension/src/background.test.ts new file mode 100644 index 00000000000..0ef86aec570 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/background.test.ts @@ -0,0 +1,150 @@ +// --------------------------------------------------------------------------- +// Chrome API mock +// +// `background.ts` registers its listeners at module load, so the mock must be +// installed on `globalThis` before the module is imported (see `beforeAll`). +// --------------------------------------------------------------------------- + +type TabUpdatedHandler = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => void; + +const EXT_ORIGIN = 'chrome-extension://testextensionid/'; +const CONNECT_URL = `${EXT_ORIGIN}connect.html`; + +const tabUpdatedListeners: TabUpdatedHandler[] = []; + +const chromeMock = { + runtime: { + getURL: vi.fn((path: string) => `${EXT_ORIGIN}${path}`), + sendMessage: vi.fn().mockResolvedValue(undefined), + onMessage: { addListener: vi.fn() }, + }, + tabs: { + query: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + reload: vi.fn().mockResolvedValue(undefined), + onCreated: { addListener: vi.fn() }, + onRemoved: { addListener: vi.fn() }, + onUpdated: { + addListener: vi.fn((fn: TabUpdatedHandler) => tabUpdatedListeners.push(fn)), + }, + }, + windows: { update: vi.fn().mockResolvedValue(undefined) }, + storage: { + session: { + set: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue({}), + remove: vi.fn().mockResolvedValue(undefined), + }, + local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) }, + }, + webNavigation: { onCreatedNavigationTarget: { addListener: vi.fn() } }, + action: { + onClicked: { addListener: vi.fn() }, + setBadgeText: vi.fn(), + setBadgeBackgroundColor: vi.fn(), + }, +}; + +Object.assign(globalThis, { chrome: chromeMock }); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function connectUrlWithRelay(relayUrl: string): string { + return `${CONNECT_URL}?mcpRelayUrl=${encodeURIComponent(relayUrl)}`; +} + +/** Invoke the registered tab-update listeners as Chrome would. */ +function simulateTabUpdated(tabId: number, url: string): void { + for (const fn of tabUpdatedListeners) fn(tabId, { url } as chrome.tabs.TabChangeInfo); +} + +/** Flush pending microtasks/macrotasks so the listener's async IIFE settles. */ +const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0)); + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeAll(async () => { + await import('./background'); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('connect.html tab deduplication', () => { + const NEW_RELAY = 'ws://localhost:2222'; + const NEW_TAB_ID = 2; + const EXISTING_TAB_ID = 1; + const EXISTING_WINDOW_ID = 10; + + it('reuses an existing connect tab without reloading it when a new relay URL arrives', async () => { + chromeMock.tabs.query.mockResolvedValue([ + { + id: EXISTING_TAB_ID, + windowId: EXISTING_WINDOW_ID, + url: connectUrlWithRelay('ws://localhost:1111'), + }, + ]); + + simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY)); + await flush(); + + expect(chromeMock.tabs.update).toHaveBeenCalledWith(EXISTING_TAB_ID, { active: true }); + expect(chromeMock.windows.update).toHaveBeenCalledWith(EXISTING_WINDOW_ID, { focused: true }); + expect(chromeMock.tabs.remove).toHaveBeenCalledWith(NEW_TAB_ID); + // Reloading would re-read the existing tab's stale ?mcpRelayUrl — regression guard. + expect(chromeMock.tabs.reload).not.toHaveBeenCalled(); + }); + + it('pushes the new relay URL to the existing tab and stores it as a fallback', async () => { + chromeMock.tabs.query.mockResolvedValue([ + { + id: EXISTING_TAB_ID, + windowId: EXISTING_WINDOW_ID, + url: connectUrlWithRelay('ws://localhost:1111'), + }, + ]); + + simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY)); + await flush(); + + expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: NEW_RELAY }); + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({ + type: 'relayUrlReady', + relayUrl: NEW_RELAY, + }); + }); + + it('does not touch other tabs when no existing connect tab is open', async () => { + // Only the freshly-opened tab matches the query — nothing to reuse. + chromeMock.tabs.query.mockResolvedValue([ + { id: NEW_TAB_ID, url: connectUrlWithRelay(NEW_RELAY) }, + ]); + + simulateTabUpdated(NEW_TAB_ID, connectUrlWithRelay(NEW_RELAY)); + await flush(); + + expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: NEW_RELAY }); + expect(chromeMock.tabs.update).not.toHaveBeenCalled(); + expect(chromeMock.tabs.remove).not.toHaveBeenCalled(); + expect(chromeMock.tabs.reload).not.toHaveBeenCalled(); + expect(chromeMock.runtime.sendMessage).not.toHaveBeenCalled(); + }); + + it('ignores tab updates that carry no relay URL', async () => { + simulateTabUpdated(NEW_TAB_ID, CONNECT_URL); + await flush(); + + expect(chromeMock.storage.session.set).not.toHaveBeenCalled(); + expect(chromeMock.tabs.query).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/background.ts b/packages/@n8n/mcp-browser-extension/src/background.ts index e67a5c45850..05d4bd71a09 100644 --- a/packages/@n8n/mcp-browser-extension/src/background.ts +++ b/packages/@n8n/mcp-browser-extension/src/background.ts @@ -6,6 +6,7 @@ */ import { createLogger } from './logger'; +import { isAllowedRelayUrl } from './relayAllowlist'; import { RelayConnection, isEligibleTab } from './relayConnection'; import type { ExtensionMessage, TabManagementSettings } from './types'; @@ -149,17 +150,16 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { // Reuse existing tab: focus it and close the duplicate log.debug('reusing existing connect.html tab:', existing.id); await chrome.tabs.update(existing.id, { active: true }); - await chrome.tabs.reload(existing.id); if (existing.windowId !== undefined) { await chrome.windows.update(existing.windowId, { focused: true }); } await chrome.tabs.remove(tabId); - // Notify existing tab about the new relay URL + // The existing tab stays loaded, so its listener is alive to apply the new relay URL. try { await chrome.runtime.sendMessage({ type: 'relayUrlReady', relayUrl }); } catch { - // Tab may not have a listener ready yet — it will read from storage on next mount + // Defensive: the stored RELAY_URL_KEY covers a missed message on next mount. } } // If no existing tab, let the new one load normally — App.vue reads relay URL from storage @@ -275,6 +275,12 @@ async function connectToRelay( selectedTabIds: number[], ): Promise<{ success: boolean; error?: string }> { log.debug('connectToRelay:', relayUrl, 'selectedTabs:', selectedTabIds.length); + + if (!isAllowedRelayUrl(relayUrl)) { + log.warn('refusing relay connection to disallowed host:', relayUrl); + return { success: false, error: 'Refusing to connect: not a recognized n8n instance.' }; + } + // Clean up existing connection disconnect(); diff --git a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts new file mode 100644 index 00000000000..191f0ed3c15 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.test.ts @@ -0,0 +1,64 @@ +import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from './relayAllowlist'; + +describe('isAllowedRelayUrl', () => { + it('allows n8n cloud tenant subdomains over wss', () => { + expect(isAllowedRelayUrl('wss://acme.app.n8n.cloud/browser-use/extension/s?token=t')).toBe( + true, + ); + expect(isAllowedRelayUrl('wss://acme.stage-app.n8n.cloud/browser-use/extension/s')).toBe(true); + }); + + it('allows the bare cloud apex', () => { + expect(isAllowedRelayUrl('wss://app.n8n.cloud/x')).toBe(true); + }); + + it('allows localhost relays for local development', () => { + expect(isAllowedRelayUrl('ws://localhost:5680/browser-use/cdp/s')).toBe(true); + expect(isAllowedRelayUrl('ws://127.0.0.1:5680/x')).toBe(true); + expect(isAllowedRelayUrl('ws://[::1]:5680/x')).toBe(true); + }); + + it('rejects unrecognized hosts', () => { + expect(isAllowedRelayUrl('wss://evil.com/x')).toBe(false); + expect(isAllowedRelayUrl('wss://notn8ncloud.com/x')).toBe(false); + }); + + it('rejects suffix-spoofing hosts', () => { + expect(isAllowedRelayUrl('wss://app.n8n.cloud.evil.com/x')).toBe(false); + expect(isAllowedRelayUrl('wss://evil-app.n8n.cloud.attacker.net/x')).toBe(false); + }); + + it('rejects non-websocket schemes', () => { + expect(isAllowedRelayUrl('https://acme.app.n8n.cloud/x')).toBe(false); + expect(isAllowedRelayUrl('http://localhost:5680/x')).toBe(false); + }); + + it('rejects malformed or empty input', () => { + expect(isAllowedRelayUrl('not a url')).toBe(false); + expect(isAllowedRelayUrl('')).toBe(false); + expect(isAllowedRelayUrl(null)).toBe(false); + expect(isAllowedRelayUrl(undefined)).toBe(false); + }); +}); + +describe('isLocalhostRelay', () => { + it('is true only for local hosts', () => { + expect(isLocalhostRelay('ws://localhost:5680/x')).toBe(true); + expect(isLocalhostRelay('ws://127.0.0.1:5680/x')).toBe(true); + expect(isLocalhostRelay('ws://[::1]:5680/x')).toBe(true); + expect(isLocalhostRelay('wss://acme.app.n8n.cloud/x')).toBe(false); + expect(isLocalhostRelay(null)).toBe(false); + }); +}); + +describe('getRelayHost', () => { + it('returns the hostname for a valid URL', () => { + expect(getRelayHost('wss://acme.app.n8n.cloud/x')).toBe('acme.app.n8n.cloud'); + }); + + it('returns null for malformed or empty input', () => { + expect(getRelayHost('not a url')).toBeNull(); + expect(getRelayHost(null)).toBeNull(); + expect(getRelayHost(undefined)).toBeNull(); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts new file mode 100644 index 00000000000..100e183e99a --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/relayAllowlist.ts @@ -0,0 +1,31 @@ +// Relay hosts the extension is permitted to connect to. +const N8N_CLOUD_SUFFIXES = ['.app.n8n.cloud', '.stage-app.n8n.cloud']; +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +export function getRelayHost(url: string | null | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function isLocalhostRelay(url: string | null | undefined): boolean { + const host = getRelayHost(url); + return host !== null && LOCAL_HOSTS.has(host); +} + +export function isAllowedRelayUrl(url: string | null | undefined): boolean { + if (!url) return false; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return false; + const host = parsed.hostname; + if (LOCAL_HOSTS.has(host)) return true; + return N8N_CLOUD_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix)); +} diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts index dd8a8bbb768..81a1909c822 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts @@ -880,3 +880,48 @@ describe('RelayConnection', () => { }); }); }); + +describe('RelayConnection keepalive', () => { + let ws: MockWebSocket; + let relay: RelayConnection; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + ws = new MockWebSocket(); + relay = new RelayConnection(ws as unknown as WebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sends keepalive frames on an interval while the socket is open', () => { + expect(ws.sent).toHaveLength(0); + + vi.advanceTimersByTime(15_000); + expect(ws.sent).toHaveLength(1); + expect(parseSent(ws)).toEqual({ method: 'keepalive' }); + + vi.advanceTimersByTime(15_000); + expect(ws.sent).toHaveLength(2); + }); + + it('stops sending keepalive frames after the connection closes', () => { + vi.advanceTimersByTime(15_000); + expect(ws.sent).toHaveLength(1); + + relay.close('done'); + ws.sent.length = 0; + + vi.advanceTimersByTime(45_000); + expect(ws.sent).toHaveLength(0); + }); + + it('does not send keepalive frames while the socket is not open', () => { + ws.readyState = MockWebSocket.CLOSED; + + vi.advanceTimersByTime(15_000); + expect(ws.sent).toHaveLength(0); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts index 5304826e311..591170cc21f 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts @@ -53,6 +53,7 @@ interface TabEntry { const CDP_COMMAND_TIMEOUT_MS = 30_000; const ATTACH_TIMEOUT_MS = 5_000; +const KEEPALIVE_INTERVAL_MS = 15_000; // --------------------------------------------------------------------------- // RelayConnection @@ -88,6 +89,7 @@ export class RelayConnection { ) => void; private readonly detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; private closed = false; + private keepaliveInterval: ReturnType | undefined; private settings: TabManagementSettings = DEFAULT_SETTINGS; onclose?: () => void; @@ -102,6 +104,8 @@ export class RelayConnection { this.detachListener = this.onDebuggerDetach.bind(this); chrome.debugger.onEvent.addListener(this.eventListener); chrome.debugger.onDetach.addListener(this.detachListener); + + this.startKeepalive(); } // ========================================================================= @@ -218,10 +222,24 @@ export class RelayConnection { // Internal — connection lifecycle // ========================================================================= + private startKeepalive(): void { + this.keepaliveInterval = setInterval(() => { + this.sendMessage({ method: 'keepalive' }); + }, KEEPALIVE_INTERVAL_MS); + } + + private stopKeepalive(): void { + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval); + this.keepaliveInterval = undefined; + } + } + private handleClose(): void { if (this.closed) return; this.closed = true; + this.stopKeepalive(); chrome.debugger.onEvent.removeListener(this.eventListener); chrome.debugger.onDetach.removeListener(this.detachListener); diff --git a/packages/@n8n/mcp-browser-extension/src/ui/App.vue b/packages/@n8n/mcp-browser-extension/src/ui/App.vue index 1e892e36a85..72bbbab6044 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/App.vue +++ b/packages/@n8n/mcp-browser-extension/src/ui/App.vue @@ -12,6 +12,8 @@ const { errorMessage, settings, hasRelayUrl, + relayHost, + isRelayAllowed, isAutoConnect, controlledTabs, controlledTabIds, @@ -39,27 +41,37 @@ const {