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:
adlee-was-taken
2026-04-20 20:57:38 -04:00
parent 1d5ad5e59e
commit 856ceb2d93
4 changed files with 257 additions and 55 deletions

View File

@@ -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';