Files
relicario/extension/src/content/fill.ts
adlee-was-taken 14397b33f0 feat(ext/content): closed Shadow DOM for icon/picker/TOFU + close fill TOCTOU
Two security fixes bundled together because they all live on the
icon-click/fill path:

1. Icon + picker + TOFU hint now render inside closed-mode Shadow DOM
   (via shadow.createShadowHost). Page scripts can no longer find our
   overlay via document.querySelector or rewrite buttons.

2. Icon's get_autofill_candidates call drops the `url` field — router
   derives origin from sender.tab.url. Similarly get_credentials.

3. Icon's get_credentials response handling was buggy: the response is a
   discriminated union { requires_ack, hostname } | { username, password }
   and the old code always read .username (→ undefined when requires_ack).
   New code dispatches on the `requires_ack` marker and either shows an
   in-page TOFU hint or fills directly.

4. fill_credentials is popup-only in the router — the icon click cannot
   (and MUST NOT) issue it from content. The new flow calls fillFields()
   directly after get_credentials returns the plaintext: the content
   script IS the origin, so no SW round-trip is needed for the typing.

5. TOCTOU on the popup → SW → content fill path: the SW verified the
   captured tab's hostname matched capturedUrl, then forwarded blindly.
   Between that check and chrome.tabs.sendMessage delivery, the tab can
   navigate; chrome.tabs.sendMessage delivers to whatever content-script
   principal is loaded at send-time. Closed by:
   - Router forwards { expectedHost: currentHost } in the payload.
   - fill.ts re-checks location.href.hostname === expectedHost before
     typing anything; on mismatch replies { ok: false, error: 'origin_changed' }
     and types nothing.

6. Remove @ts-nocheck from icon.ts, fill.ts, and detector.ts — all three
   now type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:25 -04:00

117 lines
4.2 KiB
TypeScript

/// 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<HTMLInputElement>('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<HTMLInputElement>('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;
}