From be32ea13c64dd544aa6ceb25ea9424c0cb2ba5d3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:11:02 -0400 Subject: [PATCH] feat(ext/sw): router/content-callable handlers with origin derivation --- .../service-worker/router/content-callable.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 extension/src/service-worker/router/content-callable.ts diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts new file mode 100644 index 0000000..df46b60 --- /dev/null +++ b/extension/src/service-worker/router/content-callable.ts @@ -0,0 +1,116 @@ +/// 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 { Manifest } from '../../shared/types'; +import type { GitHost } from '../git-host'; +import * as vault from '../vault'; +import * as session from '../session'; + +export interface ContentState { + manifest: Manifest | null; + gitHost: GitHost | null; +} + +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 }; + } + } +} + +async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' }) + ?? { captureEnabled: false, captureStyle: 'bar' }; +} + +async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +}