fix(core): Opt form fields out of password manager autofill (no-changelog) (#36328)

This commit is contained in:
Bernhard Wittmann 2026-08-19 09:26:15 +00:00 committed by GitHub
parent ee7584255f
commit a4e0aad0ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 468 additions and 0 deletions

View File

@ -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);
}
});
});

View File

@ -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();
});
});

View 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);
}
}
}

View File

@ -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', () => {

View File

@ -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) {