/// Content-script-callable message handlers. /// /// Origin is always derived from sender.tab.url — never trust fields on msg. /// Router has already verified sender.frameId === 0 (top-frame only) and /// sender.tab !== undefined. import type { ContentMessage, Response } from '../../shared/messages'; import type { Item, Manifest } from '../../shared/types'; import type { GitHost } from '../git-host'; import * as vault from '../vault'; import { itemToManifestEntry } from '../vault'; import * as session from '../session'; import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; export interface ContentState { manifest: Manifest | null; gitHost: GitHost | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any wasm: any; } export async function handle( msg: ContentMessage, state: ContentState, sender: chrome.runtime.MessageSender, ): Promise { const senderHost = safeHostname(sender.tab?.url ?? ''); if (!senderHost) return { ok: false, error: 'invalid_sender_url' }; switch (msg.type) { case 'get_autofill_candidates': { if (!state.manifest) return { ok: false, error: 'vault_locked' }; return { ok: true, data: { candidates: vault.findByHostname(state.manifest, senderHost) }, }; } case 'get_credentials': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' }; const itemHost = safeHostname(item.core.url ?? ''); if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' }; // TOFU origin-ack check (VaultSettings.autofill_origin_acks): const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); const acks = settings.autofill_origin_acks ?? {}; if (!(senderHost in acks)) { return { ok: true, data: { requires_ack: true, hostname: senderHost } }; } return { ok: true, data: { username: item.core.username ?? '', password: item.core.password ?? '', }, }; } case 'check_credential': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) { return { ok: true, data: { action: 'skip' } }; } // Settings-gating: capture off or site blacklisted → skip. const captureSettings = await loadDeviceSettings(); if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } }; const blacklist = await loadBlacklist(); if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } }; const candidates = vault.findByHostname(state.manifest, senderHost); if (candidates.length === 0) return { ok: true, data: { action: 'save' } }; for (const [itemId, entry] of candidates) { if (entry.type !== 'login') continue; const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId); if (full.core.type !== 'login') continue; if (full.core.username === msg.username) { if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } }; return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } }; } } return { ok: true, data: { action: 'save' } }; } case 'blacklist_site': { const bl = await loadBlacklist(); if (!bl.includes(senderHost)) { bl.push(senderHost); await saveBlacklist(bl); } return { ok: true }; } case 'capture_save_login': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; // Look for an existing login for this origin + username. Origin is // always senderHost (derived from sender.tab.url by the router) — the // content script cannot influence which host we bind to. const candidates = vault.findByHostname(state.manifest, senderHost); for (const [id, entry] of candidates) { if (entry.type !== 'login') continue; const full = await vault.fetchAndDecryptItem(state.gitHost, handle, id); if (full.core.type !== 'login') continue; if (full.core.username === msg.username) { // Defense in depth: verify the existing item's own URL hostname // matches senderHost. If it doesn't (e.g. manifest icon_hint // drifted from core.url), refuse to mutate — updating here would // silently bind a password to the wrong origin. const existingHost = safeHostname(full.core.url ?? ''); if (existingHost !== senderHost) return { ok: false, error: 'origin_mismatch' }; // Update only the password field + modified timestamp. const updated: Item = { ...full, modified: Math.floor(Date.now() / 1000), core: { ...full.core, password: msg.password }, }; await vault.encryptAndWriteItem(state.gitHost, handle, id, updated, `capture: update ${existingHost}`); state.manifest.items[id] = itemToManifestEntry(updated); await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${existingHost}`); return { ok: true, data: { action: 'updated', id } }; } } // No match → create a new Login item bound to senderHost. Title // defaults to the hostname; url is the sender's full origin when we // have it, otherwise derived from senderHost. const now = Math.floor(Date.now() / 1000); const newId = state.wasm.new_item_id(); const senderOrigin = (() => { try { return sender.tab?.url ? new URL(sender.tab.url).origin : `https://${senderHost}`; } catch { return `https://${senderHost}`; } })(); const item: Item = { id: newId, title: senderHost, type: 'login', tags: [], favorite: false, created: now, modified: now, core: { type: 'login', username: msg.username, password: msg.password, url: senderOrigin, }, sections: [], attachments: [], field_history: {}, }; await vault.encryptAndWriteItem(state.gitHost, handle, newId, item, `capture: add ${senderHost}`); state.manifest.items[newId] = itemToManifestEntry(item); await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${senderHost}`); return { ok: true, data: { action: 'added', id: newId } }; } } } function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } }