diff --git a/extension/src/content/detector.ts b/extension/src/content/detector.ts index 5090944..f369f0e 100644 --- a/extension/src/content/detector.ts +++ b/extension/src/content/detector.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Content script entry point. /// /// Detects login forms on the page by finding password fields and their diff --git a/extension/src/content/fill.ts b/extension/src/content/fill.ts index 88e00c8..ebc663c 100644 --- a/extension/src/content/fill.ts +++ b/extension/src/content/fill.ts @@ -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; diff --git a/extension/src/content/icon.ts b/extension/src/content/icon.ts index 2e4031c..a637559 100644 --- a/extension/src/content/icon.ts +++ b/extension/src/content/icon.ts @@ -1,16 +1,40 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Inject a small "id" icon into password fields for quick autofill access. /// -/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver). +/// Each injected icon and picker renders inside a closed Shadow DOM so +/// the host page cannot read or manipulate our UI. +/// +/// Flow: +/// 1. Icon click → chrome.runtime.sendMessage({ type: 'get_autofill_candidates' }) +/// (router derives origin from sender.tab.url; no url on message). +/// 2. Single candidate → get_credentials; if response is a +/// requires_ack variant, show an in-page TOFU hint instructing the +/// user to open the popup for ack. Otherwise, call fillFields() +/// directly — the content script IS the page origin, so no SW +/// round-trip for the fill itself. +/// 3. Multiple candidates → show the picker inside a shadow root. +/// +/// Note: fill_credentials is popup-only in the router. The icon click path +/// cannot and MUST NOT issue fill_credentials from content. -import type { ManifestEntry } from '../shared/types'; +import type { AutofillCandidatesResponse, CredentialsResponse, Response } from '../shared/messages'; +import type { ManifestEntry, ItemId } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; +import { fillFields } from './fill'; /// Track which fields already have an injected icon. const injected = new WeakSet(); +/// The currently-open picker / TOFU hint, if any. +let currentOverlay: ShadowSurface | null = null; + +function closeOverlay(): void { + if (currentOverlay) { + currentOverlay.destroy(); + currentOverlay = null; + } +} + /// Inject a small blue "id" icon at the right edge of a password field. -/// Clicking it queries for autofill candidates and either fills immediately -/// (single match) or shows an inline picker (multiple matches). export function injectFieldIcons( passwordField: HTMLInputElement, _usernameField: HTMLInputElement | null, @@ -18,145 +42,190 @@ export function injectFieldIcons( if (injected.has(passwordField)) return; injected.add(passwordField); - // Create the icon element. + // Each icon gets its own shadow host so page CSS cannot reach it. + const surface = createShadowHost(); + const { host, root } = surface; + + // Compute initial position from the password field's bounding rect and + // reposition on scroll/resize. We keep things lightweight — exact + // pixel-perfect tracking during layout churn is not required. + function positionHost(): void { + const rect = passwordField.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.top + rect.height / 2 - 10}px`, + `left: ${rect.right - 28}px`, + 'z-index: 2147483646', + 'pointer-events: auto', + ].join('; '); + } + positionHost(); + window.addEventListener('scroll', positionHost, true); + window.addEventListener('resize', positionHost); + const icon = document.createElement('div'); icon.textContent = 'id'; icon.setAttribute('role', 'button'); icon.setAttribute('aria-label', 'relicario autofill'); + icon.style.cssText = [ + 'width: 20px', 'height: 20px', 'line-height: 20px', + 'text-align: center', 'font-size: 10px', 'font-weight: 700', + 'font-family: monospace', 'color: #fff', 'background: #1f6feb', + 'border-radius: 3px', 'cursor: pointer', 'user-select: none', + 'box-sizing: border-box', + ].join('; '); + root.appendChild(icon); - Object.assign(icon.style, { - position: 'absolute', - right: '8px', - top: '50%', - transform: 'translateY(-50%)', - width: '20px', - height: '20px', - lineHeight: '20px', - textAlign: 'center', - fontSize: '10px', - fontWeight: '700', - fontFamily: 'monospace', - color: '#fff', - background: '#1f6feb', - borderRadius: '3px', - cursor: 'pointer', - zIndex: '999999', - userSelect: 'none', - }); - - // Ensure the password field's parent is positioned so the icon can be absolute. - const parent = passwordField.parentElement; - if (parent) { - const parentPosition = getComputedStyle(parent).position; - if (parentPosition === 'static') { - parent.style.position = 'relative'; - } - } - - // Insert the icon after the password field. - passwordField.insertAdjacentElement('afterend', icon); - - // Click handler: query for autofill candidates. icon.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); - const url = window.location.href; + // Note: no `url` on message — router derives from sender.tab.url. const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', - url, - }); + }) as Response; if (!resp || !resp.ok) return; - const candidates = resp.data.candidates as Array<[string, ManifestEntry]>; - + const candidates = (resp as AutofillCandidatesResponse).data.candidates; if (candidates.length === 0) return; if (candidates.length === 1) { - // Single match — fill immediately. - const [id] = candidates[0]; - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + await handleSingleCandidate(candidates[0][0]); } else { - // Multiple matches — show inline picker. - showPicker(icon, candidates); + showPicker(passwordField, candidates); } }); } -/// Show a small dropdown picker below the icon for selecting among multiple candidates. +/// Fetch credentials for a single item and either fill immediately or +/// display the TOFU ack hint. +async function handleSingleCandidate(id: ItemId): Promise { + const credResp = await chrome.runtime.sendMessage({ + type: 'get_credentials', + id, + }) as Response; + if (!credResp?.ok) return; + + const data = (credResp as CredentialsResponse).data; + if ('requires_ack' in data && data.requires_ack) { + showAckHint(data.hostname); + return; + } + // Discriminated union: must be the {username, password} variant here. + if ('username' in data && 'password' in data) { + fillFields(data.username, data.password); + } +} + +/// Render a dropdown picker below the password field for selecting among +/// multiple candidates. The picker lives in its own closed Shadow DOM. function showPicker( - anchor: HTMLElement, - candidates: Array<[string, ManifestEntry]>, + anchor: HTMLInputElement, + candidates: Array<[ItemId, ManifestEntry]>, ): void { - // Remove any existing picker. - document.querySelectorAll('.relicario-picker').forEach(el => el.remove()); + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + const rect = anchor.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.bottom + 4}px`, + `left: ${rect.right - 180}px`, + 'z-index: 2147483647', + ].join('; '); const picker = document.createElement('div'); - picker.className = 'relicario-picker'; - Object.assign(picker.style, { - position: 'absolute', - right: '0', - top: '100%', - marginTop: '4px', - background: '#161b22', - border: '1px solid #30363d', - borderRadius: '6px', - boxShadow: '0 4px 12px rgba(0,0,0,0.4)', - zIndex: '9999999', - minWidth: '180px', - maxHeight: '200px', - overflowY: 'auto', - fontFamily: "'JetBrains Mono', monospace", - fontSize: '12px', - }); + picker.style.cssText = [ + 'background: #161b22', 'border: 1px solid #30363d', + 'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'min-width: 180px', 'max-height: 200px', 'overflow-y: auto', + "font-family: 'JetBrains Mono', monospace", 'font-size: 12px', + ].join('; '); for (const [id, entry] of candidates) { const row = document.createElement('div'); - row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`; - Object.assign(row.style, { - padding: '8px 12px', - cursor: 'pointer', - color: '#c9d1d9', - borderBottom: '1px solid #21262d', - }); + const label = entry.title + (/* user hint */ ''); + row.textContent = label; + row.style.cssText = [ + 'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9', + 'border-bottom: 1px solid #21262d', + ].join('; '); row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); row.addEventListener('click', async (e) => { e.stopPropagation(); - picker.remove(); - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + closeOverlay(); + await handleSingleCandidate(id); }); picker.appendChild(row); } - anchor.parentElement?.appendChild(picker); + root.appendChild(picker); - // Close picker on outside click. - const closeHandler = (e: MouseEvent) => { - if (!picker.contains(e.target as Node) && e.target !== anchor) { - picker.remove(); + // Close picker on outside click (scoped to document; shadow root blocks + // composedPath for closed mode but the host element still shows up). + const closeHandler = (e: MouseEvent): void => { + if (e.target !== host) { + closeOverlay(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } + +/// TOFU origin-ack hint: credentials exist for this host but the user has +/// never explicitly acknowledged autofill here. Instruct them to open +/// relicario to confirm — we do not (and cannot) fill until ack-autofill +/// has been called from the popup. +function showAckHint(hostname: string): void { + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + host.style.cssText = [ + 'position: fixed', 'top: 16px', 'right: 16px', + 'z-index: 2147483647', + ].join('; '); + + const hint = document.createElement('div'); + hint.style.cssText = [ + 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', + 'font-size: 12px', 'color: #c9d1d9', 'background: #161b22', + 'border: 1px solid #30363d', 'border-radius: 6px', + 'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'max-width: 320px', 'line-height: 1.5', + ].join('; '); + + const title = document.createElement('div'); + title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;'; + title.textContent = 'relicario'; + hint.appendChild(title); + + const body = document.createElement('div'); + body.appendChild(document.createTextNode('First autofill on ')); + const hostSpan = document.createElement('strong'); + hostSpan.textContent = hostname; + body.appendChild(hostSpan); + body.appendChild(document.createTextNode(' — open relicario to confirm.')); + hint.appendChild(body); + + const close = document.createElement('div'); + close.textContent = '✕'; + close.style.cssText = [ + 'position: absolute', 'top: 6px', 'right: 8px', + 'cursor: pointer', 'color: #8b949e', 'font-size: 14px', + ].join('; '); + close.addEventListener('click', closeOverlay); + hint.style.position = 'relative'; + hint.appendChild(close); + + root.appendChild(hint); + + // Auto-dismiss after 8 seconds + setTimeout(() => { + if (currentOverlay === surface) closeOverlay(); + }, 8000); +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 8480913..b71f9d0 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -199,10 +199,15 @@ async function handleFillCredentials( const itemHost = safeHostname(item.core.url ?? ''); if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' }; + // Pass the hostname the SW validated. The content script re-verifies + // against location.href before filling — if the tab navigated between + // our chrome.tabs.get check above and the sendMessage delivery below, + // fill.ts rejects with 'origin_changed'. await chrome.tabs.sendMessage(msg.capturedTabId, { type: 'fill_credentials', username: item.core.username ?? '', password: item.core.password ?? '', + expectedHost: currentHost, }); return { ok: true }; }