/// Fill listener — receives credentials from the service worker popup flow, /// verifies origin, and fills page fields. /// /// TOCTOU mitigation: the popup captures its active tab at open time and /// passes {capturedTabId, capturedUrl, expectedHost} to the SW. The SW /// re-fetches the tab and checks the hostname against `capturedUrl` before /// forwarding, but between the SW's chrome.tabs.sendMessage and our receipt /// the page could navigate. We re-check `location.href.hostname === /// expectedHost` before typing credentials. If the page has navigated /// (different origin now running the content script), reply with /// `origin_changed` and do nothing. /// Message shape forwarded by router/popup-only.ts#handleFillCredentials. export interface FillMessage { type: 'fill_credentials'; username: string; password: string; /// The hostname the SW validated the captured tab was on. The content /// script rejects delivery if the page has since navigated away. expectedHost: string; } /// Set up a listener for fill_credentials messages from the service worker. export function setupFillListener(): void { chrome.runtime.onMessage.addListener( ( message: FillMessage, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean; error?: string }) => void, ) => { if (message.type !== 'fill_credentials') return false; const currentHost = (() => { try { return new URL(location.href).hostname; } catch { return ''; } })(); if (!currentHost || currentHost !== message.expectedHost) { sendResponse({ ok: false, error: 'origin_changed' }); return false; } fillFields(message.username, message.password); sendResponse({ ok: true }); return false; }, ); } /// Fill username and password fields on the page. /// /// Finds the first visible password field and its associated username field, /// then sets their values using the native setter trick for React/Vue compat. export function fillFields(username: string, password: string): void { const pwField = document.querySelector('input[type="password"]'); if (!pwField) return; // Set the password. setNativeValue(pwField, password); // Find the username field (same logic as detector). if (username) { const usernameField = findUsernameForFill(pwField); if (usernameField) { setNativeValue(usernameField, username); } } } /// Use the native HTMLInputElement.value setter to bypass React/Vue wrappers. /// Then dispatch input and change events so the framework picks up the change. function setNativeValue(input: HTMLInputElement, value: string): void { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', )?.set; if (nativeSetter) { nativeSetter.call(input, value); } else { input.value = value; } input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } /// Find the username field associated with a password field (simplified version for fill). function findUsernameForFill(pwField: HTMLInputElement): HTMLInputElement | null { const form = pwField.closest('form'); const scope = form ?? document; const inputs = scope.querySelectorAll('input'); // Priority: autocomplete > type=email > name pattern > preceding text input. for (const input of inputs) { if (input === pwField) continue; if (input.autocomplete === 'username' || input.autocomplete === 'email') return input; } for (const input of inputs) { if (input === pwField) continue; if (input.type === 'email') return input; } const pattern = /user|email|login|account/i; for (const input of inputs) { if (input === pwField || input.type === 'hidden' || input.type === 'password') continue; if (pattern.test(input.name) || pattern.test(input.id)) return input; } const allInputs = Array.from(inputs); const pwIndex = allInputs.indexOf(pwField); for (let i = pwIndex - 1; i >= 0; i--) { const input = allInputs[i]; if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue; if (input.offsetWidth > 0 && input.offsetHeight > 0) return input; } return null; }