fix(ext): content-callable capture_save_login closes critical router gap
After Slice 4's router split, the capture prompt's Save button was silently failing on every site: content/capture.ts called four handlers (get_settings, get_item, update_item, add_item) that are all in POPUP_ONLY_TYPES, so the router rejected each with unauthorized_sender. Fix in two parts: Part A — get_settings: content scripts already have storage permission via the manifest, so read relicarioSettings directly from chrome.storage.local instead of round-tripping through the SW. Part B — new content-callable 'capture_save_login' message that consolidates what was previously three separate popup-only calls (get_item + update_item or add_item) into one SW-side operation. Content scripts no longer need to distinguish add vs update — the SW does that itself from the manifest. Security model (all enforced SW-side, never trusting content): - Origin is derived from sender.tab.url by the router. The payload contains only username + password; there is no way for content to influence which host the new/updated item binds to. - Update path re-verifies the existing item's core.url hostname matches senderHost before mutating. If the manifest icon_hint ever drifts from core.url, we return origin_mismatch rather than silently binding a password to the wrong origin. - Update mutates ONLY the password field + modified timestamp — never title, url, or any other core field. - Add path creates a new Login item whose title is senderHost and whose url is the sender's origin. Five new router tests cover: content-accept, popup-reject, update path rotates only the password, add path creates bound item, and origin_mismatch when the stored item's host disagrees with senderHost. Tests: 47 -> 52. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
/// are applied via textContent, never innerHTML.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { DeviceSettings, Item, LoginCore } from '../shared/types';
|
||||
import type { DeviceSettings } from '../shared/types';
|
||||
import { createShadowHost, type ShadowSurface } from './shadow';
|
||||
|
||||
// --- State ---
|
||||
@@ -93,14 +93,15 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
const data = resp.data as { action: string; entryId?: string; entryName?: string };
|
||||
if (data.action === 'skip') return;
|
||||
|
||||
// Fetch settings for prompt style
|
||||
const settingsResp = await sendMessage({ type: 'get_settings' });
|
||||
const defaults: DeviceSettings = { captureEnabled: true, captureStyle: 'bar' };
|
||||
const settings: DeviceSettings = settingsResp.ok
|
||||
? ((settingsResp.data as { settings: DeviceSettings }).settings ?? defaults)
|
||||
: defaults;
|
||||
// Fetch settings for prompt style. Content scripts have direct
|
||||
// chrome.storage.local access (manifest grants "storage"), so we don't
|
||||
// need to round-trip through the SW for this — which also avoids the
|
||||
// router's content→popup-only rejection for 'get_settings'.
|
||||
const stored = await chrome.storage.local.get('relicarioSettings');
|
||||
const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings)
|
||||
?? { captureEnabled: true, captureStyle: 'bar' };
|
||||
|
||||
showPrompt(settings.captureStyle, data.action, username, password, data.entryId);
|
||||
showPrompt(settings.captureStyle, data.action, username, password);
|
||||
}
|
||||
|
||||
// --- Prompt UI ---
|
||||
@@ -117,14 +118,12 @@ function showPrompt(
|
||||
action: string,
|
||||
username: string,
|
||||
password: string,
|
||||
entryId?: string,
|
||||
): void {
|
||||
removeExistingPrompt();
|
||||
|
||||
const hostname = (() => {
|
||||
try { return new URL(window.location.href).hostname; } catch { return window.location.href; }
|
||||
})();
|
||||
const url = window.location.href;
|
||||
|
||||
const surface = createShadowHost();
|
||||
currentPrompt = surface;
|
||||
@@ -236,52 +235,15 @@ function showPrompt(
|
||||
if (autoDismissTimer) clearTimeout(autoDismissTimer);
|
||||
};
|
||||
|
||||
// Save button
|
||||
// Save button — single content-callable message; the SW figures out
|
||||
// whether this is an add or an update (and enforces origin-binding).
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
clearAutoDismiss();
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const loginCore: LoginCore & { type: 'login' } = {
|
||||
type: 'login',
|
||||
username,
|
||||
password,
|
||||
url,
|
||||
};
|
||||
|
||||
if (action === 'update' && entryId) {
|
||||
// For update we need a valid Item — fetch the existing one, merge the
|
||||
// updated login fields, and write it back. The router's update_item
|
||||
// expects a full Item. We fall back to a minimal item if fetch fails.
|
||||
const getResp = await sendMessage({ type: 'get_item', id: entryId });
|
||||
if (getResp.ok) {
|
||||
const existing = (getResp.data as { item: Item }).item;
|
||||
const updated: Item = {
|
||||
...existing,
|
||||
title: existing.title || hostname,
|
||||
modified: now,
|
||||
core: { ...existing.core, ...loginCore },
|
||||
};
|
||||
await sendMessage({ type: 'update_item', id: entryId, item: updated });
|
||||
}
|
||||
} else {
|
||||
// New item — SW will assign the id; we just pass an empty string.
|
||||
const item: Item = {
|
||||
id: '',
|
||||
title: hostname,
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
core: loginCore,
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
await sendMessage({ type: 'add_item', item });
|
||||
const resp = await sendMessage({ type: 'capture_save_login', username, password });
|
||||
if (!resp.ok) {
|
||||
msgSpan.textContent = `✗ ${resp.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
msgSpan.textContent = '✓ Saved';
|
||||
saveBtn.style.display = 'none';
|
||||
neverBtn.style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user