mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: N8n browser extension supports connecting directly to n8n instance (#32783)
This commit is contained in:
@@ -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": ["<all_urls>"],
|
||||
"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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof setInterval> | 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);
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ const {
|
||||
errorMessage,
|
||||
settings,
|
||||
hasRelayUrl,
|
||||
relayHost,
|
||||
isRelayAllowed,
|
||||
isAutoConnect,
|
||||
controlledTabs,
|
||||
controlledTabIds,
|
||||
@@ -39,27 +41,37 @@ const {
|
||||
|
||||
<template v-if="status !== 'connected'">
|
||||
<template v-if="hasRelayUrl">
|
||||
<TabList
|
||||
v-if="tabs.length"
|
||||
:tabs="tabs"
|
||||
selectable
|
||||
:selected-tab-ids="selectedTabIds"
|
||||
:all-selected="allSelected"
|
||||
@toggle-tab="toggleTab"
|
||||
@toggle-all="toggleAll"
|
||||
/>
|
||||
<N8nButton
|
||||
class="full-width"
|
||||
size="large"
|
||||
:disabled="status === 'connecting'"
|
||||
@click="connect"
|
||||
>
|
||||
Connect{{
|
||||
someSelected
|
||||
? ` (${selectedTabIds.size} tab${selectedTabIds.size !== 1 ? 's' : ''})`
|
||||
: ''
|
||||
}}
|
||||
</N8nButton>
|
||||
<template v-if="isRelayAllowed">
|
||||
<p class="connect-notice">
|
||||
You're about to connect to <strong>{{ relayHost }}</strong
|
||||
>. Only continue if you started this connection yourself.
|
||||
</p>
|
||||
<TabList
|
||||
v-if="tabs.length"
|
||||
:tabs="tabs"
|
||||
selectable
|
||||
:selected-tab-ids="selectedTabIds"
|
||||
:all-selected="allSelected"
|
||||
@toggle-tab="toggleTab"
|
||||
@toggle-all="toggleAll"
|
||||
/>
|
||||
<N8nButton
|
||||
class="full-width"
|
||||
size="large"
|
||||
:disabled="status === 'connecting'"
|
||||
@click="connect"
|
||||
>
|
||||
Connect{{
|
||||
someSelected
|
||||
? ` (${selectedTabIds.size} tab${selectedTabIds.size !== 1 ? 's' : ''})`
|
||||
: ''
|
||||
}}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<p v-else class="error">
|
||||
Can't connect to <strong>{{ relayHost || 'this address' }}</strong> — it isn't a valid n8n
|
||||
instance.
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="info-text">
|
||||
Waiting for n8n AI to connect. Ask n8n AI to open your browser to get started.
|
||||
@@ -125,6 +137,13 @@ const {
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.connect-notice {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
color: var(--color--text);
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -639,6 +639,25 @@ describe('useConnection', () => {
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('strips the stale connection params from the page URL', async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?mcpRelayUrl=' + encodeURIComponent('ws://localhost:1111') + '&autoConnect=1',
|
||||
);
|
||||
|
||||
const { wrapper, result } = mountComposable();
|
||||
await flush();
|
||||
|
||||
pushMessage({ type: 'relayUrlReady', relayUrl: 'ws://localhost:9999' });
|
||||
await flush();
|
||||
|
||||
expect(result().relayUrl.value).toBe('ws://localhost:9999');
|
||||
expect(window.location.search).toBe('');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { createLogger } from '../../logger';
|
||||
import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from '../../relayAllowlist';
|
||||
import { isEligibleTab } from '../../relayConnection';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
@@ -57,6 +58,8 @@ export function useConnection() {
|
||||
|
||||
// ── Computeds ─────────────────────────────────────────────────────────────
|
||||
const hasRelayUrl = computed(() => !!relayUrl.value);
|
||||
const relayHost = computed(() => getRelayHost(relayUrl.value));
|
||||
const isRelayAllowed = computed(() => isAllowedRelayUrl(relayUrl.value));
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
@@ -124,6 +127,12 @@ export function useConnection() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedRelayUrl(relayUrl.value)) {
|
||||
errorMessage.value = `Can't connect to ${relayHost.value ?? 'this address'} — not a recognized n8n instance.`;
|
||||
log.warn('connect: relay URL not allowed', relayUrl.value);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('connect: relay URL =', relayUrl.value, 'selectedTabs:', selectedTabIds.size);
|
||||
status.value = 'connecting';
|
||||
errorMessage.value = '';
|
||||
@@ -174,6 +183,9 @@ export function useConnection() {
|
||||
if (message.type === 'relayUrlReady' && message.relayUrl) {
|
||||
log.debug('relayUrlReady received:', message.relayUrl);
|
||||
relayUrl.value = message.relayUrl;
|
||||
// Drop the now-stale connection params from the page URL. The live value lives in
|
||||
// relayUrl + session storage, so a manual reload reads the fresh URL, not the old token.
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
if (status.value === 'connected') {
|
||||
status.value = 'disconnected';
|
||||
controlledTabIds.value = []; // controlledTabDetails auto-computes to []
|
||||
@@ -282,6 +294,8 @@ export function useConnection() {
|
||||
settings,
|
||||
relayUrl,
|
||||
hasRelayUrl,
|
||||
relayHost,
|
||||
isRelayAllowed,
|
||||
isAutoConnect,
|
||||
controlledTabs: controlledTabDetails,
|
||||
allSelected,
|
||||
@@ -293,18 +307,3 @@ export function useConnection() {
|
||||
updateSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the relay URL points to the local machine. Used to gate
|
||||
* the `?autoConnect=1` shortcut so a crafted chrome-extension URL with a
|
||||
* remote `mcpRelayUrl` cannot trigger an unattended connect.
|
||||
*/
|
||||
function isLocalhostRelay(url: string | null): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user