mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Opt form fields out of password manager autofill (no-changelog) (#36328)
This commit is contained in:
parent
ee7584255f
commit
a4e0aad0ec
|
|
@ -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('<input id="a"><textarea id="b"></textarea><select id="c"></select>');
|
||||
|
||||
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('<input>');
|
||||
|
||||
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('<textarea id="a"></textarea><div id="b" contenteditable></div>');
|
||||
|
||||
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('<input id="a">');
|
||||
|
||||
run();
|
||||
|
||||
expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips explicitly non-editable contenteditable elements', () => {
|
||||
html('<div id="a" contenteditable="false"></div>');
|
||||
|
||||
run();
|
||||
|
||||
expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves attributes the page already set untouched', () => {
|
||||
html('<input id="a" data-form-type="password">');
|
||||
|
||||
run();
|
||||
|
||||
expect(document.getElementById('a')?.getAttribute('data-form-type')).toBe('password');
|
||||
});
|
||||
|
||||
it('reaches fields inside open shadow roots', () => {
|
||||
html('<div id="host"></div>');
|
||||
const shadow = document.getElementById('host')!.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML = '<input id="inner">';
|
||||
|
||||
run();
|
||||
|
||||
expect(shadow.getElementById('inner')?.hasAttribute('data-1p-ignore')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not throw on a page with no fields', () => {
|
||||
html('<p>nothing here</p>');
|
||||
|
||||
expect(() => run()).not.toThrow();
|
||||
});
|
||||
|
||||
it('marks a field inserted after it ran', async () => {
|
||||
html('<div id="app"></div>');
|
||||
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('<div id="app"></div>');
|
||||
run();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.innerHTML = '<form><input id="nested"></form>';
|
||||
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('<div id="host"></div>');
|
||||
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('<div id="app"></div>');
|
||||
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 <html>, let alone <body>, 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
140
packages/@n8n/mcp-browser-extension/src/documentPreparation.ts
Normal file
140
packages/@n8n/mcp-browser-extension/src/documentPreparation.ts
Normal file
|
|
@ -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 <body> 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<Node> {
|
||||
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<Node>();
|
||||
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<void> {
|
||||
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<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<void>((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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | 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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user