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>
This commit is contained in:
@@ -1,13 +1,41 @@
|
||||
/// Fill listener — receives credentials from the service worker and fills form fields.
|
||||
/// Fill listener — receives credentials from the service worker popup flow,
|
||||
/// verifies origin, and fills page fields.
|
||||
///
|
||||
/// Uses the native value setter trick to work with React/Vue controlled inputs
|
||||
/// that override the value property.
|
||||
/// 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: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => {
|
||||
(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user