feat(ext/sw): router/content-callable handlers with origin derivation

This commit is contained in:
adlee-was-taken
2026-04-20 20:11:02 -04:00
parent 533bfd5bea
commit be32ea13c6

View File

@@ -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<Response> {
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<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}