diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 544a34e..4d26078 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -1,16 +1,22 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Credential capture module. /// /// Detects login form submissions and prompts the user to save or update /// credentials in the vault. Supports bar and toast prompt styles. +/// +/// The prompt renders inside a closed Shadow DOM so the host page cannot +/// read overlay contents via document.querySelector or rewrite them via +/// insertAdjacentHTML. All caller-supplied strings (hostname, username) +/// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; -import type { RelicarioSettings } from '../shared/types'; +import type { DeviceSettings, Item, LoginCore } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- const hookedForms = new WeakSet(); const hookedButtons = new WeakSet(); +let currentPrompt: ShadowSurface | null = null; // --- Messaging --- @@ -74,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { if (!password) return; const username = findUsernameValue(pwField); - const url = window.location.href; + // Note: `url` is NOT sent — router derives origin from sender.tab.url. const resp = await sendMessage({ type: 'check_credential', - url, username, password, }); @@ -90,58 +95,64 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { // Fetch settings for prompt style const settingsResp = await sendMessage({ type: 'get_settings' }); - const settings: RelicarioSettings = settingsResp.ok - ? (settingsResp.data as { settings: RelicarioSettings }).settings - : { captureEnabled: true, captureStyle: 'bar' }; + const defaults: DeviceSettings = { captureEnabled: true, captureStyle: 'bar' }; + const settings: DeviceSettings = settingsResp.ok + ? ((settingsResp.data as { settings: DeviceSettings }).settings ?? defaults) + : defaults; - showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId); + showPrompt(settings.captureStyle, data.action, username, password, data.entryId); } // --- Prompt UI --- function removeExistingPrompt(): void { - const existing = document.getElementById('relicario-capture-prompt'); - if (existing) existing.remove(); + if (currentPrompt) { + currentPrompt.destroy(); + currentPrompt = null; + } } function showPrompt( style: 'bar' | 'toast', action: string, - url: string, username: string, password: string, entryId?: string, ): void { removeExistingPrompt(); - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - hostname = url; + 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; + const { host, root } = surface; + + // Position the host on the page; all further styling lives inside the + // shadow root so the page's CSS can't reach us. + const baseHostStyles = 'z-index: 2147483647; position: fixed;'; + if (style === 'bar') { + host.style.cssText = `${baseHostStyles} top:0; left:0; right:0;`; + } else { + host.style.cssText = `${baseHostStyles} bottom:16px; right:16px;`; } - const container = document.createElement('div'); - container.id = 'relicario-capture-prompt'; + // --- Build prompt DOM via createElement / textContent only --- - // Common styles - const baseStyles = [ + const container = document.createElement('div'); + const containerBase = [ 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', 'font-size: 13px', 'color: #c9d1d9', 'background: #161b22', - 'z-index: 2147483647', 'box-sizing: border-box', 'line-height: 1.4', ]; - if (style === 'bar') { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'top: 0', - 'left: 0', - 'right: 0', + ...containerBase, 'padding: 10px 16px', 'display: flex', 'align-items: center', @@ -153,10 +164,7 @@ function showPrompt( ].join('; '); } else { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'bottom: 16px', - 'right: 16px', + ...containerBase, 'padding: 12px 16px', 'border-radius: 4px', 'border: 1px solid #30363d', @@ -168,30 +176,46 @@ function showPrompt( } const actionLabel = action === 'update' ? 'Update' : 'Save'; - const displayUser = username ? ` (${username})` : ''; - container.innerHTML = ` - - ${actionLabel} login for ${escapeForHtml(hostname)}${escapeForHtml(displayUser)}? - - - - - `; + // Message span: " login for ()?" + const msgSpan = document.createElement('span'); + msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'; + msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `)); + const hostStrong = document.createElement('strong'); + hostStrong.style.color = '#58a6ff'; + hostStrong.textContent = hostname; + msgSpan.appendChild(hostStrong); + if (username) { + msgSpan.appendChild(document.createTextNode(` (${username})`)); + } + msgSpan.appendChild(document.createTextNode('?')); - document.body.appendChild(container); + const saveBtn = document.createElement('button'); + saveBtn.textContent = actionLabel; + saveBtn.style.cssText = [ + 'background:#1f6feb', 'color:#fff', 'border:none', 'padding:5px 14px', + 'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px', + 'white-space:nowrap', + ].join('; '); + + const neverBtn = document.createElement('button'); + neverBtn.textContent = 'Never'; + neverBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:1px solid #30363d', + 'padding:5px 10px', 'border-radius:3px', 'cursor:pointer', + 'font-family:inherit', 'font-size:12px', 'white-space:nowrap', + ].join('; '); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:none', + 'cursor:pointer', 'font-size:16px', 'padding:2px 6px', + 'font-family:inherit', 'line-height:1', + ].join('; '); + + container.append(msgSpan, saveBtn, neverBtn, closeBtn); + root.appendChild(container); // Animate in requestAnimationFrame(() => { @@ -213,67 +237,71 @@ function showPrompt( }; // Save button - container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => { + saveBtn.addEventListener('click', async () => { clearAutoDismiss(); - const now = new Date().toISOString(); + const now = Math.floor(Date.now() / 1000); + const loginCore: LoginCore & { type: 'login' } = { + type: 'login', + username, + password, + url, + }; + if (action === 'update' && entryId) { - await sendMessage({ - type: 'update_entry', - id: entryId, - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); + // 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 { - await sendMessage({ - type: 'add_entry', - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); + // 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 }); } // Show confirmation - const span = container.querySelector('span'); - if (span) span.textContent = '\u2713 Saved'; - const saveBtn = container.querySelector('#relicario-save-btn') as HTMLElement | null; - const neverBtn = container.querySelector('#relicario-never-btn') as HTMLElement | null; - if (saveBtn) saveBtn.style.display = 'none'; - if (neverBtn) neverBtn.style.display = 'none'; + msgSpan.textContent = '✓ Saved'; + saveBtn.style.display = 'none'; + neverBtn.style.display = 'none'; setTimeout(() => removeExistingPrompt(), 1500); }); - // Never button - container.querySelector('#relicario-never-btn')?.addEventListener('click', async () => { + // Never button: router derives hostname from sender.tab.url (no `hostname` field) + neverBtn.addEventListener('click', async () => { clearAutoDismiss(); - await sendMessage({ type: 'blacklist_site', hostname }); + await sendMessage({ type: 'blacklist_site' }); removeExistingPrompt(); }); // Close button - container.querySelector('#relicario-close-btn')?.addEventListener('click', () => { + closeBtn.addEventListener('click', () => { clearAutoDismiss(); removeExistingPrompt(); }); } -function escapeForHtml(str: string): string { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - // --- Form hooking --- export function hookForms(): void { diff --git a/extension/src/content/shadow.ts b/extension/src/content/shadow.ts new file mode 100644 index 0000000..d76aad2 --- /dev/null +++ b/extension/src/content/shadow.ts @@ -0,0 +1,37 @@ +/// Closed Shadow DOM host helper. +/// +/// All in-page UI (capture prompt, autofill icon, candidate picker, TOFU +/// banner) mounts into a closed-mode ShadowRoot so the host page cannot +/// read or mutate the overlay via document.querySelector / DOM APIs. The +/// returned ShadowSurface provides {host, root, destroy} for callers that +/// want to populate the root, position the host, and tear everything down. + +export interface ShadowSurface { + /// The host
that's appended to document.body. Style/position this + /// from the caller (position: fixed, z-index, transform, etc.). + host: HTMLDivElement; + /// Closed-mode ShadowRoot. Populate via textContent / appendChild — + /// NEVER innerHTML, NEVER insertAdjacentHTML. Treat any caller-supplied + /// string (hostname, username) as untrusted. + root: ShadowRoot; + /// Remove the host from the DOM and drop all references. + destroy: () => void; +} + +/// Create a closed Shadow DOM host attached to document.body. +/// +/// Callers are responsible for positioning `host` and filling `root`. +export function createShadowHost(): ShadowSurface { + const host = document.createElement('div'); + // Reset host-side styling so page CSS cannot leak in/out via inheritance. + host.style.all = 'initial'; + const root = host.attachShadow({ mode: 'closed' }); + document.body.appendChild(host); + return { + host, + root, + destroy: () => { + host.remove(); + }, + }; +}