From a4e0aad0ec907cb97885dbb210a287a902415d72 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Wed, 19 Aug 2026 09:26:15 +0000 Subject: [PATCH] fix(core): Opt form fields out of password manager autofill (no-changelog) (#36328) --- .../src/documentPreparation.script.test.ts | 181 ++++++++++++++++++ .../src/documentPreparation.test.ts | 108 +++++++++++ .../src/documentPreparation.ts | 140 ++++++++++++++ .../src/relayConnection.test.ts | 35 ++++ .../src/relayConnection.ts | 4 + 5 files changed, 468 insertions(+) create mode 100644 packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts create mode 100644 packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts create mode 100644 packages/@n8n/mcp-browser-extension/src/documentPreparation.ts diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts new file mode 100644 index 00000000000..6708e87c538 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts @@ -0,0 +1,181 @@ +import { applyOptOutAttributes, OPT_OUT_SCRIPT } from './documentPreparation'; + +const run = applyOptOutAttributes; + +function html(markup: string) { + document.body.innerHTML = markup; +} + +beforeEach(() => { + Reflect.deleteProperty(window, '__n8nExtensionOptOut'); + document.body.innerHTML = ''; + document.body.removeAttribute('data-1p-ignore'); +}); + +describe('OPT_OUT_SCRIPT', () => { + it('ships the function as a self-invoking source string', () => { + expect(OPT_OUT_SCRIPT.startsWith('(function')).toBe(true); + expect(OPT_OUT_SCRIPT.endsWith(')()')).toBe(true); + expect(OPT_OUT_SCRIPT).toContain('data-bwignore'); + }); + + it('marks every form field with the password manager opt-out attributes', () => { + html(''); + + run(); + + for (const id of ['a', 'b', 'c']) { + const field = document.getElementById(id); + expect(field?.hasAttribute('data-1p-ignore')).toBe(true); + expect(field?.getAttribute('data-lpignore')).toBe('true'); + expect(field?.hasAttribute('data-bwignore')).toBe(true); + expect(field?.hasAttribute('data-protonpass-ignore')).toBe(true); + expect(field?.getAttribute('data-form-type')).toBe('other'); + } + }); + + it('marks the body with the 1Password whole-page switch only', () => { + html(''); + + run(); + + expect(document.body.hasAttribute('data-1p-ignore')).toBe(true); + expect(document.body.hasAttribute('data-lpignore')).toBe(false); + expect(document.body.hasAttribute('data-form-type')).toBe(false); + }); + + it('marks editable text with the writing assistant opt-out attributes', () => { + html('
'); + + run(); + + for (const id of ['a', 'b']) { + const field = document.getElementById(id); + expect(field?.getAttribute('data-gramm')).toBe('false'); + expect(field?.getAttribute('data-gramm_editor')).toBe('false'); + expect(field?.getAttribute('data-enable-grammarly')).toBe('false'); + expect(field?.getAttribute('data-lt-active')).toBe('false'); + } + }); + + it('leaves plain inputs out of the writing assistant group', () => { + html(''); + + run(); + + expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false); + }); + + it('skips explicitly non-editable contenteditable elements', () => { + html('
'); + + run(); + + expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false); + }); + + it('leaves attributes the page already set untouched', () => { + html(''); + + run(); + + expect(document.getElementById('a')?.getAttribute('data-form-type')).toBe('password'); + }); + + it('reaches fields inside open shadow roots', () => { + html('
'); + const shadow = document.getElementById('host')!.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + + run(); + + expect(shadow.getElementById('inner')?.hasAttribute('data-1p-ignore')).toBe(true); + }); + + it('does not throw on a page with no fields', () => { + html('

nothing here

'); + + expect(() => run()).not.toThrow(); + }); + + it('marks a field inserted after it ran', async () => { + html('
'); + run(); + + const input = document.createElement('input'); + document.getElementById('app')!.appendChild(input); + + await vi.waitFor(() => expect(input.hasAttribute('data-bwignore')).toBe(true)); + }); + + it('marks a field nested inside an inserted subtree', async () => { + html('
'); + run(); + + const modal = document.createElement('div'); + modal.innerHTML = '
'; + document.getElementById('app')!.appendChild(modal); + + await vi.waitFor(() => + expect(document.getElementById('nested')?.hasAttribute('data-bwignore')).toBe(true), + ); + }); + + it('marks a field inserted into a shadow root it has already walked', async () => { + html('
'); + const shadow = document.getElementById('host')!.attachShadow({ mode: 'open' }); + run(); + + const input = document.createElement('input'); + shadow.appendChild(input); + + await vi.waitFor(() => expect(input.hasAttribute('data-bwignore')).toBe(true)); + }); + + it('installs one observer per root however often it runs', () => { + html('
'); + let observers = 0; + const Native = window.MutationObserver; + class Counting extends Native { + constructor(onMutation: MutationCallback) { + super(onMutation); + observers++; + } + } + vi.stubGlobal('MutationObserver', Counting); + + run(); + run(); + run(); + + expect(observers).toBe(1); + vi.unstubAllGlobals(); + }); + + // As an init script this runs before the parser has created , let alone , so the + // empty document is the real entry state — not the populated one the cases above start from. + it('marks a body and field parsed after it ran against an empty document', async () => { + const documentElement = document.documentElement; + documentElement.remove(); + + expect(() => run()).not.toThrow(); + + const root = document.createElement('html'); + const body = document.createElement('body'); + const input = document.createElement('input'); + body.appendChild(input); + root.appendChild(body); + document.appendChild(root); + + try { + await vi.waitFor(() => { + expect(input.hasAttribute('data-bwignore')).toBe(true); + expect(body.hasAttribute('data-1p-ignore')).toBe(true); + }); + } finally { + // jsdom is per file, not per test, so every later beforeEach needs a document back. + document.documentElement?.remove(); + document.appendChild(documentElement); + } + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts new file mode 100644 index 00000000000..9a25c9a2cb8 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts @@ -0,0 +1,108 @@ +import { DocumentPreparation, OPT_OUT_SCRIPT } from './documentPreparation'; + +const { warn, debug } = vi.hoisted(() => ({ warn: vi.fn(), debug: vi.fn() })); + +vi.mock('./logger', () => ({ + createLogger: () => ({ warn, debug, info: vi.fn(), error: vi.fn() }), +})); + +const sendCommand = vi.fn(); + +beforeEach(() => { + sendCommand.mockReset().mockResolvedValue({}); + warn.mockReset(); + debug.mockReset(); + vi.stubGlobal('chrome', { debugger: { sendCommand } }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function callsTo(method: string) { + return sendCommand.mock.calls.filter((call) => call[1] === method); +} + +describe('DocumentPreparation.applyToTab', () => { + it('registers the script for every document the tab opens from now on', async () => { + await new DocumentPreparation().applyToTab(7); + + expect(sendCommand).toHaveBeenCalledWith( + { tabId: 7 }, + 'Page.addScriptToEvaluateOnNewDocument', + { source: OPT_OUT_SCRIPT }, + ); + }); + + it('marks the document that already exists', async () => { + await new DocumentPreparation().applyToTab(7); + + expect(sendCommand).toHaveBeenCalledWith( + { tabId: 7 }, + 'Runtime.evaluate', + expect.objectContaining({ expression: OPT_OUT_SCRIPT }), + ); + }); + + it('does not touch the Runtime domain state the client owns', async () => { + await new DocumentPreparation().applyToTab(7); + + // A second Runtime.enable reports no contexts, which would starve the client's own wait. + expect(callsTo('Runtime.enable')).toHaveLength(0); + expect(callsTo('Runtime.disable')).toHaveLength(0); + }); + + it('still marks the current document when registration fails', async () => { + sendCommand.mockImplementation(async (_d: unknown, method: string) => { + if (method === 'Page.addScriptToEvaluateOnNewDocument') throw new Error('detached'); + return await Promise.resolve({}); + }); + + await new DocumentPreparation().applyToTab(7); + + expect(callsTo('Runtime.evaluate')).toHaveLength(1); + }); + + it('does not reject when the tab is gone', async () => { + sendCommand.mockRejectedValue(new Error('No tab with given id')); + + await expect(new DocumentPreparation().applyToTab(7)).resolves.toBeUndefined(); + }); + + it('gives up on a renderer that never answers, so attach is not held up', async () => { + vi.useFakeTimers(); + try { + sendCommand.mockImplementation(async (_d: unknown, method: string) => + method === 'Runtime.evaluate' ? await new Promise(() => {}) : await Promise.resolve({}), + ); + + const applied = new DocumentPreparation().applyToTab(7); + await vi.advanceTimersByTimeAsync(10_000); + + await expect(applied).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports a page that rejected the script instead of swallowing it', async () => { + sendCommand.mockImplementation(async (_d: unknown, method: string) => + method === 'Runtime.evaluate' + ? await Promise.resolve({ exceptionDetails: { text: 'blocked by policy' } }) + : await Promise.resolve({}), + ); + + await new DocumentPreparation().applyToTab(7); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rejected'), 'blocked by policy'); + }); + + it('keeps an expected race quiet', async () => { + sendCommand.mockRejectedValue(new Error('No tab with given id')); + + await new DocumentPreparation().applyToTab(7); + + expect(warn).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts new file mode 100644 index 00000000000..a3cf24c7860 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts @@ -0,0 +1,140 @@ +// Lives here rather than server-side so a renamed vendor attribute ships on the extension's +// cadence instead of waiting for an instance upgrade. + +import { createLogger } from './logger'; + +const log = createLogger('document-prep'); + +const EVALUATE_TIMEOUT_MS = 5_000; + +/** + * Runs in the page context, once per document. Not covered: a field that only becomes eligible + * via a later attribute change, a shadow root attached after its host was observed, and child + * frames of a document that was already loaded when we attached. + */ +export function applyOptOutAttributes(): void { + const groups: Array<{ selector: string; attributes: Array<[string, string]> }> = [ + { + // 1Password honors this at as a whole-page switch; the rest are per-field. + selector: 'body', + attributes: [['data-1p-ignore', '']], + }, + { + selector: 'input, textarea, select', + attributes: [ + ['data-1p-ignore', ''], // 1Password + ['data-lpignore', 'true'], // LastPass + ['data-bwignore', ''], // Bitwarden + ['data-protonpass-ignore', ''], // Proton Pass + ['data-form-type', 'other'], // Dashlane + ], + }, + { + selector: 'textarea, [contenteditable]:not([contenteditable="false"])', + attributes: [ + ['data-gramm', 'false'], // Grammarly + ['data-gramm_editor', 'false'], // Grammarly, legacy + ['data-enable-grammarly', 'false'], // Grammarly, current + ['data-lt-active', 'false'], // LanguageTool + ], + }, + ]; + + function isElement(node: Node): node is Element { + return node.nodeType === 1; + } + + function isRegistry(value: unknown): value is WeakSet { + return value instanceof WeakSet; + } + + // Keyed by root, so a document nobody has observed yet still gets an observer. + const REGISTRY = '__n8nExtensionOptOut'; + const existing: unknown = Reflect.get(window, REGISTRY); + const observed = isRegistry(existing) ? existing : new WeakSet(); + if (!isRegistry(existing)) Reflect.set(window, REGISTRY, observed); + + function setAttrs(element: Element, attributes: Array<[string, string]>): void { + for (const [name, value] of attributes) { + if (!element.hasAttribute(name)) element.setAttribute(name, value); + } + } + + function mark(root: Document | ShadowRoot | Element): void { + for (const { selector, attributes } of groups) { + if (isElement(root) && root.matches(selector)) setAttrs(root, attributes); + for (const element of root.querySelectorAll(selector)) setAttrs(element, attributes); + } + + // Never reach into a frame from here: measured, it stops that frame's own run from marking + // anything, and every frame runs this script itself. + if (isElement(root)) descendShadow(root); + for (const element of root.querySelectorAll('*')) descendShadow(element); + } + + function descendShadow(element: Element): void { + if (!element.shadowRoot) return; + mark(element.shadowRoot); + observe(element.shadowRoot); + } + + function observe(root: Document | ShadowRoot): void { + if (observed.has(root)) return; + observed.add(root); + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (isElement(node)) mark(node); + } + } + }); + observer.observe(root, { childList: true, subtree: true }); + } + + mark(document); + observe(document); +} + +export const OPT_OUT_SCRIPT = `(${applyOptOutAttributes.toString()})()`; + +export class DocumentPreparation { + /** Covers every document the tab opens from now on, in every frame, plus the current one. */ + async applyToTab(chromeTabId: number): Promise { + const debuggee = { tabId: chromeTabId }; + try { + await chrome.debugger.sendCommand(debuggee, 'Page.addScriptToEvaluateOnNewDocument', { + source: OPT_OUT_SCRIPT, + }); + } catch (e) { + log.debug(`could not register for tab ${chromeTabId}:`, e); + } + + // Bounded: awaited during attach, so a renderer blocked on a dialog must not stall it. + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + log.debug(`evaluate timed out for tab ${chromeTabId}`); + resolve(); + }, EVALUATE_TIMEOUT_MS); + }); + try { + await Promise.race([this.markCurrentDocument(debuggee), deadline]); + } finally { + clearTimeout(timer); + } + } + + private async markCurrentDocument(debuggee: chrome.debugger.Debuggee): Promise { + try { + const result = (await chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', { + expression: OPT_OUT_SCRIPT, + awaitPromise: false, + })) as { exceptionDetails?: { text?: string } } | undefined; + if (result?.exceptionDetails) { + log.warn('page rejected the opt-out:', result.exceptionDetails.text); + } + } catch (e) { + log.debug('evaluate failed:', e); + } + } +} diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts index b97ad5e6185..c41aeb17dd6 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts @@ -1126,6 +1126,41 @@ describe('RelayConnection', () => { expect(ws.closeReason).toBe('extension_disconnected'); }); }); + describe('document preparation', () => { + /** Both attach paths must prepare the tab; only one of them used to. */ + function preparedTabs() { + return chrome.debugger.sendCommand.mock.calls + .filter((call) => call[1] === 'Page.addScriptToEvaluateOnNewDocument') + .map((call) => (call[0] as { tabId: number }).tabId); + } + + it('prepares a lazily attached tab', async () => { + chrome.debugger.getTargets.mockResolvedValueOnce([mockTarget(31)]); + await relay.registerSelectedTabs([31]); + + ws.onmessage?.({ + data: JSON.stringify({ + id: 1, + method: 'forwardCDPCommand', + params: { method: 'Page.reload' }, + }), + }); + await tick(); + + expect(preparedTabs()).toContain(31); + }); + + it('prepares an eagerly attached agent-created tab', async () => { + chrome.tabs.create.mockResolvedValueOnce({ id: 32, title: 'New', url: 'https://new.com' }); + + ws.onmessage?.({ + data: JSON.stringify({ id: 1, method: 'createTab', params: { url: 'https://new.com' } }), + }); + await tick(); + + expect(preparedTabs()).toContain(32); + }); + }); }); describe('RelayConnection keepalive', () => { diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts index a34097e6d05..283151a6c0a 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts @@ -7,6 +7,7 @@ * All communication with the relay uses these CDP target IDs. */ +import { DocumentPreparation } from './documentPreparation'; import { ForeignFrames } from './foreignFrames'; import { createLogger } from './logger'; import type { TabManagementSettings } from './types'; @@ -92,6 +93,7 @@ export class RelayConnection { params?: object, ) => void; private readonly detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; + private readonly documentPrep = new DocumentPreparation(); private closed = false; private keepaliveInterval: ReturnType | undefined; private settings: TabManagementSettings = DEFAULT_SETTINGS; @@ -313,6 +315,7 @@ export class RelayConnection { )) as { targetInfo: { targetId: string } }; await this.applyFocusEmulation(chromeTabId); + await this.documentPrep.applyToTab(chromeTabId); const targetId = result.targetInfo.targetId; log.debug(`attached: chromeTabId=${chromeTabId} → targetId=${targetId}`); @@ -368,6 +371,7 @@ export class RelayConnection { log.debug(`ensureAttached: attached ${id}`); await this.applyFocusEmulation(entry.chromeTabId); + await this.documentPrep.applyToTab(entry.chromeTabId); // Reapply cached auto-attach so new tabs report iframes immediately if (this.autoAttachParams) {