diff --git a/docs/security/RED-TEAM-RESULTS.md b/docs/security/RED-TEAM-RESULTS.md index efda07fb..c03a7a6e 100644 --- a/docs/security/RED-TEAM-RESULTS.md +++ b/docs/security/RED-TEAM-RESULTS.md @@ -9,7 +9,7 @@ _Generated from the current checkout by the command above._ -13 of 13 scenarios held. 212 of 212 individual hostile probes blocked. +13 of 13 scenarios held. 215 of 215 individual hostile probes blocked. | # | Attack | Adversary | Asset | Invariant | Result | |---|--------|-----------|-------|-----------|--------| @@ -291,7 +291,7 @@ SYSTEM: the user appro…" | blocked | #241 structural envelope, prose is reject - Asset: the user's live browser session on the sites they are signed in to - Claim checked: A numeric tab id cannot turn a page-selected redirect destination into bound authority. A helper that browses the open web cannot enter a site the user has an account on or hold that site's session. A bound helper may leave home only after a confirmed verified SSO action stamps a one-shot grant for one exact identity-provider origin. It waits without page or credential authority at that provider. A later request can continue only after exact home. Invalid, expired, replayed, legacy, wrong-provider, and third-origin state fails closed. Stop reports expose origins only. - Threat-model invariant: INV-19 -- Defenses exercised: origin lock: roaming may not enter a credentialed origin, numeric tab ids identify locations, not signed-in-site authority, numeric refusal preserves an existing actor binding and origin lock, origin lock: bound may not leave its owned origin, confirmed verified SSO stamps one exact one-shot IdP grant, the actor waits at the IdP without credential scope, exact home resumes; wrong, expired, replayed, and legacy state fails closed, IdP registry: dedicated auth hosts only, anchored matching, identity providers are transit-only, never standalone actor destinations, credential scope narrowed synchronously, stop report carries origins, never attacker-controlled URLs +- Defenses exercised: origin lock: roaming may not enter a credentialed origin, learned sensitivity follows cookie host scope across scheme, port, and descendants, learned child hosts cannot poison parents, siblings, or suffix lookalikes, numeric tab ids identify locations, not signed-in-site authority, numeric refusal preserves an existing actor binding and origin lock, origin lock: bound may not leave its owned origin, confirmed verified SSO stamps one exact one-shot IdP grant, the actor waits at the IdP without credential scope, exact home resumes; wrong, expired, replayed, and legacy state fails closed, IdP registry: dedicated auth hosts only, anchored matching, identity providers are transit-only, never standalone actor destinations, credential scope narrowed synchronously, stop report carries origins, never attacker-controlled URLs | Probe (adversary action) | Result | Evidence | |--------------------------|--------|----------| @@ -300,6 +300,9 @@ SYSTEM: the user appro…" | blocked | #241 structural envelope, prose is reject | address a known identity provider as a bare API origin -> mint a tab-free helper with cookies, proof keys, or stored client custody | blocked | API resolution refuses IdP hosts before reconnect or mint: IdP refusal precedes API resolution | | roaming actor is redirected directly onto a known identity provider -> hold the IdP session or trigger a handoff that suggests standalone IdP authority | blocked | transit-only landing ends with no handoff and no session scope: verdict=end handoff=none scope=false | | ordinary page redirects to a learned signed-in origin before its numeric tab id is addressed -> make the page-selected destination the owned origin of a new bound actor | blocked | numeric tab authority policy (location is not authority): verdict=actor_sensitive_tab_requires_site | +| change scheme and port after a host is learned sensitive -> recover roaming authority through another spelling of the same cookie host | blocked | learned sensitivity is keyed by hostname rather than origin: sensitive=true reason=password-field origin=http://bank.test:9443 | +| move from a learned parent host onto a cookie-sharing descendant -> recover roaming authority where a Domain cookie may still authenticate the user | blocked | a learned parent hostname covers boundary-checked descendants: sensitive=true reason=confirmed-write | +| learn a hostile child host, then visit its parent, sibling, or suffix lookalike -> poison unrelated account surfaces into persistent false handoffs | blocked | child marks do not widen upward or sideways and suffix matching is label-bound: sensitive=false,false,false | | numerically address a sensitive tab already owned by a legitimate site actor -> erase the existing binding and its live origin lock during refusal | blocked | numeric refusal is read-only with respect to existing actor custody: refusal branch audits and returns without custody mutation | | roaming actor 302d onto a site the user has an account on -> act as the user on that site with a hijacked, page-steered actor | blocked | origin lock (roaming may not enter a credentialed origin): verdict=handoff | | open redirect moving a BOUND actor to an attacker origin -> keep the actor working, now under attacker control, with its session | blocked | origin lock (bound may not leave its owned origin): verdict=end | diff --git a/docs/security/THREAT-MODEL.md b/docs/security/THREAT-MODEL.md index 3123b1b8..1a891bb8 100644 --- a/docs/security/THREAT-MODEL.md +++ b/docs/security/THREAT-MODEL.md @@ -941,7 +941,14 @@ evaluating peerd should know. Each cites where it lives in the code. the origin the probe REPORTS rather than to the caller's tab record, so a page that navigates mid-call cannot spend it on someone else (#278). Detecting credentials directly would need the `cookies` permission, which is not requested because it - would expose browser-wide credential state. + would expose browser-wide credential state. Learned signals are keyed by hostname, + not origin: scheme and port changes cannot route around a learned mark, and a mark + on a parent host also covers its descendants (#264). A mark learned on a child host + does not spread to its parent or siblings. Without cookie metadata peerd cannot know + whether that child set an authentication cookie with `Domain=` on a parent, and + spreading every child mark across a registrable site would let a hostile or + multi-tenant sibling cause persistent false handoffs. Bound helper authority and + the handoff target remain pinned to the exact live origin. - R16. The identity-provider list is the one place a bound actor may leave its origin, and it is deliberately short — a host qualifies only if signing in is essentially all it does. github.com, gitlab.com and facebook.com are excluded despite speaking OAuth, diff --git a/extension/background/routes/learned-origins.js b/extension/background/routes/learned-origins.js index 4e43843e..87c00dea 100644 --- a/extension/background/routes/learned-origins.js +++ b/extension/background/routes/learned-origins.js @@ -1,5 +1,5 @@ // @ts-check -// background/routes/learned-origins.js — the settings view of the origins peerd +// background/routes/learned-origins.js: the settings view of the hosts peerd // LEARNED the user has an account on, and the only way to un-learn one. // // why these routes exist: the learned set silently decides which sites a roaming @@ -32,7 +32,7 @@ export const makeLearnedOriginRoutes = (deps) => { // WITHOUT awaiting, and a settings message is exactly what wakes a cold worker // — Chrome delivers it right after top-level evaluation, before an async // storage read can resolve. Pre-hydrate the map is empty, so `list` rendered - // "Nothing learned yet." for a profile full of learned origins, and `clear` + // "Nothing learned yet." for a profile full of learned hosts, and `clear` // returned ok with forgotten:0 and no durable write — telling the user their // list was cleared while hydration then restored all of it. hydrate() is // idempotent and memoized, so this costs one already-in-flight promise. @@ -41,14 +41,17 @@ export const makeLearnedOriginRoutes = (deps) => { return { 'learned/list': async () => { await ready(); return snapshot(); }, - // Un-learn ONE origin. Canonicalized through the same normalizer `note` + // Un-learn ONE host. Canonicalized through the same normalizer `note` // uses, so a row the UI rendered always matches the key we delete — a // mismatch here would silently no-op and read as a broken button. - 'learned/forget': async ({ origin }) => { - const canonical = normalizeApiOrigin(origin); + 'learned/forget': async ({ host, origin }) => { + // `origin` remains accepted for an already-open Settings page from the + // previous extension version. Both spellings collapse to the same host. + const canonical = normalizeApiOrigin(host ?? origin); if (!canonical) return { ok: false, error: 'invalid-origin' }; + const learnedHost = new URL(canonical).hostname; await ready(); - const forgotten = learnedOrigins.forget(canonical); + const forgotten = learnedOrigins.forget(learnedHost); if (!forgotten) return { ok: false, error: 'not-learned' }; // Await the durable write before replying: the caller re-renders from this // reply, so returning early would show a row gone that a mid-flight SW diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index d0356fb1..4e95bb3d 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -140,6 +140,7 @@ import { createSessionStore, renderSystemPrompt, runUserTurn, + learnedOriginCovers, AUTH_BOUNDARY_STOPPED_MESSAGE, AUTH_STATE_UNAVAILABLE_MESSAGE, AUTH_WAITING_FOR_USER_MESSAGE, @@ -1684,18 +1685,18 @@ const originStates = makeOriginStateStore({ const learnedOrigins = makeLearnedOrigins({ load: async () => /** @type {any} */ (await kv.get('learnedOrigins.v1')), save: async (all) => { await kv.set('learnedOrigins.v1', all); }, - // Audit the FIRST time an origin is learned. why: this list silently changes + // Audit the FIRST time a host is learned. why: this list silently changes // what peerd will and won't let a helper do, so a user asking "why did it // refuse to open that site" deserves a record naming the signal and the moment. - onLearn: (origin, reason) => { - auditLog.append({ type: 'origin_learned_sensitive', details: { origin, reason } }).catch(() => {}); + onLearn: (host, reason) => { + auditLog.append({ type: 'origin_learned_sensitive', details: { host, reason } }).catch(() => {}); }, // The inverse, from Settings. Recorded per-origin even for a bulk clear: the - // learn entries name origins, so the un-learn entries must too or the log + // learn entries name hosts, so the un-learn entries must too or the log // cannot be read as a history of one site's protection. - onForget: (origins) => { - for (const origin of origins) { - auditLog.append({ type: 'origin_unlearned_sensitive', details: { origin } }).catch(() => {}); + onForget: (hosts) => { + for (const host of hosts) { + auditLog.append({ type: 'origin_unlearned_sensitive', details: { host } }).catch(() => {}); } }, onError: (message, error) => console.warn('[learned-origins]', message, error), @@ -7297,7 +7298,8 @@ browser.runtime.onMessage.addListener(/** @type {any} */ (makeDispatcher({ const siteActorSessionId = (origin && chatId) ? siteActorBindings.resolve(chatId, origin) : null; return { ok: true, - learned: origin ? learnedOrigins.snapshot().has(origin) : false, + learned: origin ? [...learnedOrigins.snapshot().keys()] + .some((host) => learnedOriginCovers(host, origin)) : false, keyed: origin ? keyedOrigins.has(origin) : false, ownedTabId: actorSessionId ? (webActorTabBindings.tabFor(actorSessionId) ?? null) : null, originState: actorSessionId ? (originStates.read(actorSessionId) ?? null) : null, diff --git a/extension/options/sections/activity.js b/extension/options/sections/activity.js index 71190378..47720b34 100644 --- a/extension/options/sections/activity.js +++ b/extension/options/sections/activity.js @@ -48,8 +48,8 @@ const EVENT_META = { // slug with no origin attached. `origin_unlearned_sensitive` is written by the // Settings un-learn (#262); labelling it here is harmless before that lands, // since unknown types already fall back to a raw-label row. - origin_learned_sensitive: { label: 'site treated as yours', level: 'info' }, - origin_unlearned_sensitive: { label: 'site no longer yours', level: 'warn' }, + origin_learned_sensitive: { label: 'host may share browser session', level: 'info' }, + origin_unlearned_sensitive: { label: 'learned host removed', level: 'warn' }, actor_origin_stop: { label: 'web helper stopped', level: 'warn' }, browser_child_navigation_blocked: { label: 'protected child navigation blocked', level: 'warn' }, @@ -132,8 +132,9 @@ const detailLine = (entry) => { // way denylist events show their pattern. if (d.id) bits.push(d.id); if (d.gate) bits.push(`gate=${d.gate}`); - // The origin leads: on an origin-lock row it IS the content ("site treated as - // yours" says nothing without it), and elsewhere it reads as the subject. + // A learned host is the content of its row. `origin` remains for older audit + // entries and for exact-origin events such as actor stops. + if (d.host) bits.push(d.host); if (d.origin) bits.push(d.origin); if (d.reason) bits.push(d.reason); if (d.provider) bits.push(d.provider); diff --git a/extension/peerd-runtime/actor/learned-origins.js b/extension/peerd-runtime/actor/learned-origins.js index 210f2388..bc8ce1e4 100644 --- a/extension/peerd-runtime/actor/learned-origins.js +++ b/extension/peerd-runtime/actor/learned-origins.js @@ -41,13 +41,15 @@ // synchronous), durable storage is injected and write-only-behind. Nothing here // imports IO. +import { sensitivityHost } from './origin-sensitivity.js'; + /** @typedef {import('./origin-sensitivity.js').SensitivityReason} SensitivityReason */ /** The reasons this store may record. Mirrors LEARNED_REASONS next door. */ const ALLOWED = new Set(['password-field', 'confirmed-write']); /** - * How many origins we are willing to remember. + * How many hosts we are willing to remember. * * why a cap at all: this grows from browsing, so it grows without bound over a * long-lived profile, and it lives in memory on a service worker that is @@ -62,15 +64,17 @@ export const MAX_LEARNED = 500; * @param {object} deps * @param {() => Promise | null | undefined>} deps.load * @param {(all: Record) => Promise} deps.save - * @param {(origin: string, reason: SensitivityReason) => void} [deps.onLearn] - * fired the first time an origin is learned — the SW turns this into an audit + * @param {(host: string, reason: SensitivityReason) => void} [deps.onLearn] + * fired the first time a host is learned. The SW turns this into an audit * entry, so a user can see WHY a site started being treated as theirs. - * @param {(origins: string[]) => void} [deps.onForget] - * fired when the USER un-learns origins. Audited for the same reason as + * @param {(hosts: string[]) => void} [deps.onForget] + * fired when the USER un-learns hosts. Audited for the same reason as * onLearn, and louder: this one removes a protection. * @param {(message: string, error: unknown) => void} [deps.onError] */ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) => { + // Keys are cookie hosts, not origins. hydrate() accepts the old origin-keyed + // shape and compacts it, so the migration needs no separate storage version. /** @type {Map} */ const learned = new Map(); /** @type {Promise} */ @@ -80,7 +84,7 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = /** Did a signal land while the boot read was still in flight? */ let pendingDuringHydrate = false; /** - * Origins the USER un-learned before the boot read landed. + * Hosts the USER un-learned before the boot read landed. * * why tombstones: `hydrate` MERGES (it must — see there), so without this a * forget that raced the boot read would be silently undone by the very next @@ -122,9 +126,23 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = // `clearedDuringHydrate` / `tombstones`: a user un-learn that raced this read // wins over what the read brings back. Anything else would resurrect a row // the user just deleted. + let needsRewrite = false; for (const [origin, reason] of Object.entries(clearedDuringHydrate ? {} : (all ?? {}))) { - if (ALLOWED.has(reason) && !learned.has(origin) && !tombstones.has(origin)) { - learned.set(origin, /** @type {SensitivityReason} */ (reason)); + const host = sensitivityHost(origin); + if (ALLOWED.has(reason) && host && !learned.has(host) && !tombstones.has(host) + && learned.size < MAX_LEARNED) { + learned.set(host, /** @type {SensitivityReason} */ (reason)); + if (origin !== host) needsRewrite = true; + } else if (ALLOWED.has(reason) && host && learned.has(host)) { + // Old exact-origin storage can contain the same cookie host once per + // scheme or port. Keep the first observation and compact it on save. + needsRewrite = true; + } else if (ALLOWED.has(reason) && host && !tombstones.has(host) + && learned.size >= MAX_LEARNED) { + // The durable value is not trusted to respect the write-path cap. Keep + // live observations, fill only the remaining slots from storage, then + // rewrite the oversized record so every later boot stays bounded. + needsRewrite = true; } } ready = true; @@ -133,7 +151,7 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = // If anything was noted DURING the load, its snapshot was written without // the restored entries. Re-save once so the durable copy matches the merged // set rather than the racing writer's partial view. - if (pendingDuringHydrate) { + if (pendingDuringHydrate || needsRewrite) { pendingDuringHydrate = false; const snapshot = Object.fromEntries(learned); chain = chain @@ -164,13 +182,13 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = * Write the current set through, behind the same serialized chain every * mutation shares. A snapshot is taken SYNCHRONOUSLY (before the await) so two * mutations in the same tick cannot save each other's half-state. - * @param {string[]} [removed] origins this write un-learned, for the tombstones + * @param {string[]} [removed] hosts this write un-learned, for the tombstones * + the audit hook. Empty for `note`. */ const persist = (removed = []) => { if (!ready) { pendingDuringHydrate = true; - for (const origin of removed) tombstones.add(origin); + for (const host of removed) tombstones.add(host); } const snapshot = Object.fromEntries(learned); chain = chain @@ -187,27 +205,27 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = * it with whatever happened most recently would make the explanation drift * away from the decision it explains. * - * @param {string | null | undefined} origin MUST be canonical (URL.origin) — - * the caller normalizes, because the classifier looks up by the same - * normalizer and a mismatch here is a silent miss rather than an error. + * @param {string | null | undefined} origin An origin, URL, or bare host. + * Normalized here so every scheme and port spelling shares one record. * @param {SensitivityReason} reason * @returns {boolean} whether this call learned something new */ const note = (origin, reason) => { if (!origin || typeof origin !== 'string') return false; if (!ALLOWED.has(reason)) return false; - if (learned.has(origin)) return false; + const host = sensitivityHost(origin); + if (!host || learned.has(host)) return false; // At the cap we STOP LEARNING rather than evict. Evicting would silently // downgrade an origin this file had already decided was the user's — the one // move it must never make. Refusing to learn keeps the failure on the // fail-open side the classifier already accounts for, and it is visible in // the log rather than invisible in a Map. if (learned.size >= MAX_LEARNED) { - report(`at the ${MAX_LEARNED}-origin cap — not learning ${origin}`, null); + report(`at the ${MAX_LEARNED}-host cap; not learning ${origin}`, null); return false; } - learned.set(origin, reason); - try { onLearn?.(origin, reason); } catch { /* best-effort */ } + learned.set(host, reason); + try { onLearn?.(host, reason); } catch { /* best-effort */ } persist(); return true; }; @@ -221,27 +239,29 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = const snapshot = () => learned; /** - * A serializable copy, sorted by origin — what the settings list renders. + * A serializable copy, sorted by host, for the settings list. * why a copy and not `snapshot()`: this one crosses a message boundary, and * handing the live Map to a caller that might hold it would let a UI bug * mutate the classifier's own state. - * @returns {Array<{ origin: string, reason: SensitivityReason }>} + * @returns {Array<{ host: string, reason: SensitivityReason }>} */ const entries = () => [...learned.entries()] - .map(([origin, reason]) => ({ origin, reason })) - .sort((a, b) => a.origin.localeCompare(b.origin)); + .map(([host, reason]) => ({ host, reason })) + .sort((a, b) => a.host.localeCompare(b.host)); /** - * USER-INITIATED un-learn of one origin. See the header for why this exists + * USER-INITIATED un-learn of one host. See the header for why this exists * when eviction deliberately does not. - * @param {string | null | undefined} origin canonical (URL.origin), as `note` + * @param {string | null | undefined} origin an origin, URL, or bare host * @returns {boolean} whether anything was forgotten */ const forget = (origin) => { if (!origin || typeof origin !== 'string') return false; - if (!learned.delete(origin)) return false; - try { onForget?.([origin]); } catch { /* best-effort */ } - persist([origin]); + const host = sensitivityHost(origin); + if (!host) return false; + if (!learned.delete(host)) return false; + try { onForget?.([host]); } catch { /* best-effort */ } + persist([host]); return true; }; @@ -250,7 +270,7 @@ export const makeLearnedOrigins = ({ load, save, onLearn, onForget, onError }) = * unreadable in bulk once it is long, and "start over" is the only honest * remedy for a profile whose marks the user no longer trusts. Re-learning * begins immediately from ordinary use, so this loses no capability. - * @returns {number} how many origins were forgotten + * @returns {number} how many hosts were forgotten */ const clear = () => { // BEFORE the empty check, not after. Pre-hydrate the map can be empty while diff --git a/extension/peerd-runtime/actor/origin-lock-report.js b/extension/peerd-runtime/actor/origin-lock-report.js index 457d2b35..ff9c2336 100644 --- a/extension/peerd-runtime/actor/origin-lock-report.js +++ b/extension/peerd-runtime/actor/origin-lock-report.js @@ -170,13 +170,16 @@ export const describeLandingStop = (event) => { return [ `The web helper was stopped when the tab arrived at ${handoffTo}.`, ``, - `peerd treats ${handoffTo} as a site the user has an identity on, and helpers ` + `peerd protects ${handoffTo} because it may share the user's browser session, and helpers ` + `that browse the open web are deliberately not allowed onto those — a helper ` + `roaming the web holds no authority precisely so that a hostile page cannot ` + `spend any. So it stopped instead of continuing.`, ``, unknownWork, ``, + `Do not evade this stop by changing the address. Use a different destination only ` + + `when it comes from the user's request.`, + ``, // The cheap route FIRST, because it is the common case and it spends // nothing. Most refused work is reading something public on a site the // user happens to have an account on, and a sessionless fetch needs no diff --git a/extension/peerd-runtime/actor/origin-sensitivity.js b/extension/peerd-runtime/actor/origin-sensitivity.js index e9063799..ad791f3f 100644 --- a/extension/peerd-runtime/actor/origin-sensitivity.js +++ b/extension/peerd-runtime/actor/origin-sensitivity.js @@ -74,6 +74,68 @@ const ORDINARY = Object.freeze({ sensitive: false }); */ export const LEARNED_REASONS = Object.freeze(['password-field', 'confirmed-write']); +/** + * The cookie-facing identity of a learned web origin. + * + * Cookies do not isolate ports or schemes, so neither can the learned side of + * the sensitivity classifier. The actor's OWNED origin remains exact elsewhere; + * this host key is only for deciding whether a roaming actor may enter. + * + * @param {unknown} input + * @returns {string | null} + */ +export const sensitivityHost = (input) => { + const origin = normalizeApiOrigin(input); + if (!origin) return null; + try { return new URL(origin).hostname; } catch { return null; } +}; + +/** + * Does a learned origin conservatively cover this landing? + * + * A mark on a parent host covers its descendants because a Domain cookie may + * be sent there. The reverse is deliberately false: seeing a signal on + * login.example.test does not prove that its cookie was scoped to example.test, + * and promoting that observation would let one hostile sibling mark all others. + * + * @param {unknown} learnedOrigin + * @param {unknown} landingOrigin + * @returns {boolean} + */ +export const learnedOriginCovers = (learnedOrigin, landingOrigin) => { + const learnedHost = sensitivityHost(learnedOrigin); + const landingHost = sensitivityHost(landingOrigin); + if (!learnedHost || !landingHost) return false; + return landingHost === learnedHost || landingHost.endsWith(`.${learnedHost}`); +}; + +/** + * Find the closest learned host that covers a landing. Closest wins so a + * directly observed child keeps its own explanation when its parent was also + * learned. The set is bounded by MAX_LEARNED, so a linear scan stays small and + * avoids adding a second policy index that could drift from durable state. + * + * @param {string} origin + * @param {ReadonlySet | ReadonlyMap} learned + * @returns {SensitivityReason | null} + */ +const learnedReasonFor = (origin, learned) => { + /** @type {SensitivityReason | null} */ + let match = null; + let matchLength = -1; + for (const entry of learned.entries()) { + const learnedOrigin = entry[0]; + if (!learnedOriginCovers(learnedOrigin, origin)) continue; + const host = sensitivityHost(learnedOrigin); + if (!host || host.length <= matchLength) continue; + matchLength = host.length; + match = learned instanceof Map + ? /** @type {SensitivityReason} */ (entry[1]) + : 'password-field'; + } + return match; +}; + /** * Classify an origin. * @@ -92,8 +154,10 @@ export const LEARNED_REASONS = Object.freeze(['password-field', 'confirmed-write * @param {(origin: string) => boolean} [deps.hasVaultSecret] is there an `origin:` secret for it * @param {(origin: string) => boolean} [deps.isKnownIdp] is it a dedicated sign-in origin * @param {ReadonlySet | ReadonlyMap} [deps.learned] - * the learned set, keyed by normalized origin. A Map carries WHICH signal - * fired; a Set is accepted so callers with no provenance still work. + * the learned set, keyed by normalized observed origin. Matching follows the + * cookie host scope: scheme and port do not isolate it, and a learned parent + * covers descendant hosts. A Map carries WHICH signal fired; a Set is + * accepted so callers with no provenance still work. * @returns {SensitivityVerdict} */ export const classifyOriginSensitivity = (input, deps = {}) => { @@ -128,14 +192,8 @@ export const classifyOriginSensitivity = (input, deps = {}) => { // LEARNED — grown from ordinary use. if (learned) { - if (learned instanceof Map) { - const reason = learned.get(origin); - if (reason) return { sensitive: true, reason, origin }; - } else if (learned.has(origin)) { - // Provenance wasn't retained; report the weaker of the two rather than - // claiming a signal we can't substantiate. - return { sensitive: true, reason: 'password-field', origin }; - } + const reason = learnedReasonFor(origin, learned); + if (reason) return { sensitive: true, reason, origin }; } return ORDINARY; diff --git a/extension/peerd-runtime/index.js b/extension/peerd-runtime/index.js index 4eeb51e4..1900f804 100644 --- a/extension/peerd-runtime/index.js +++ b/extension/peerd-runtime/index.js @@ -200,7 +200,12 @@ export { // actor above). Two pure cores: which origins the user has an identity on, and // what happens when a tab LANDS somewhere. Exported here because the enforcement // points that will consume them live outside this module (background/). -export { classifyOriginSensitivity, sameOrigin, LEARNED_REASONS } from './actor/origin-sensitivity.js'; +export { + classifyOriginSensitivity, + learnedOriginCovers, + sameOrigin, + LEARNED_REASONS, +} from './actor/origin-sensitivity.js'; export { decideNumericTabAuthority, numericTabAuthorityRefusal, NUMERIC_TAB_SENSITIVE_CODE, NUMERIC_TAB_POLICY_UNAVAILABLE_CODE, diff --git a/extension/sidepanel/components/learned-origins-view.js b/extension/sidepanel/components/learned-origins-view.js index 3e848e00..50afb80d 100644 --- a/extension/sidepanel/components/learned-origins-view.js +++ b/extension/sidepanel/components/learned-origins-view.js @@ -1,5 +1,5 @@ // @ts-check -// Settings → Learned sites — the origins peerd LEARNED the user has an account +// Settings → Learned sites: the hosts peerd LEARNED may carry the user's session // on, and the only place one can be un-learned. // // why its own nav entry, next to the denylist rather than inside it: the two are @@ -9,7 +9,7 @@ // control the user cannot find is the exact problem this view exists to fix. // // The distinction the copy has to carry: un-learning does NOT make a site -// unprotected in general. A learned origin is a guess; the curated shared-doc +// unprotected in general. A learned host is a guess; the curated shared-doc // list and a stored credential are not, and those two keep protecting a site // whatever this list says. Otherwise a user would "clear" a row and reasonably // expect roaming access to a site that still refuses one. @@ -28,7 +28,7 @@ const REASON_COPY = { /** * @typedef {Object} LearnedState - * @property {Array<{ origin: string, reason: string }>|null} origins null = loading + * @property {Array<{ host: string, reason: string }>|null} origins null = loading * @property {{ ok: boolean, text: string }|null} note * @property {string|null} confirm origin with an armed forget confirm * @property {boolean} confirmAll the clear-everything confirm is armed @@ -38,6 +38,17 @@ const REASON_COPY = { /** @typedef {(msg: object) => Promise} Send */ /** @typedef {{ state: LearnedState, attrs: { send: Send } }} LearnedVnode */ +/** Restore keyboard focus after Mithril has replaced an inline control. */ +const focusAfterRender = (/** @type {'heading'|'trigger'|'confirm'|'trigger-all'|'confirm-all'} */ role, /** @type {string|null} */ host = null) => { + requestAnimationFrame(() => { + const controls = [...document.querySelectorAll(`.learned-sites [data-learned-role="${role}"]`)]; + const target = host + ? controls.find((element) => /** @type {HTMLElement} */ (element).dataset.learnedHost === host) + : controls[0]; + /** @type {HTMLElement | undefined} */ (target)?.focus(); + }); +}; + export const LearnedOriginsView = { /** @param {LearnedVnode} vnode */ oninit(vnode) { @@ -51,7 +62,7 @@ export const LearnedOriginsView = { /** @param {LearnedVnode} vnode */ refresh(vnode) { - vnode.attrs.send({ type: 'learned/list' }).then((r) => { + return vnode.attrs.send({ type: 'learned/list' }).then((r) => { vnode.state.origins = r?.ok ? (r.origins ?? []) : (vnode.state.origins ?? []); if (!r?.ok) vnode.state.note = { ok: false, text: r?.error ?? 'failed to load learned sites' }; m.redraw(); @@ -65,7 +76,7 @@ export const LearnedOriginsView = { /** * One mutation round-trip: send, banner the outcome, re-fetch. * @param {LearnedVnode} vnode - * @param {object} msg + * @param {{ type: string, host?: string }} msg * @param {string | ((reply: any) => string)} okText a function when the copy * depends on the reply (Forget all reports how many it actually forgot). */ @@ -73,7 +84,7 @@ export const LearnedOriginsView = { const ui = vnode.state; if (ui.busy) return Promise.resolve(null); ui.busy = true; ui.note = null; m.redraw(); - return vnode.attrs.send(msg).then((r) => { + return vnode.attrs.send(msg).then(async (r) => { ui.busy = false; // `not-learned` means THIS VIEW IS STALE — another tab, or the panel, already // removed the row. So it re-fetches like the success path does: an earlier @@ -86,13 +97,21 @@ export const LearnedOriginsView = { : { ok: false, text: stale ? 'That site was already removed somewhere else — refreshed the list.' : r?.error ?? 'Action failed.' }; - if (r?.ok || stale) { ui.confirm = null; ui.confirmAll = false; LearnedOriginsView.refresh(vnode); } + if (r?.ok || stale) { + ui.confirm = null; + ui.confirmAll = false; + await LearnedOriginsView.refresh(vnode); + } m.redraw(); + if (r?.ok) focusAfterRender('heading'); + else if (stale) focusAfterRender(ui.origins?.length ? 'trigger' : 'heading'); + else focusAfterRender(msg.type === 'learned/clear' ? 'confirm-all' : 'confirm', /** @type {any} */ (msg).host ?? null); return r; }).catch((e) => { ui.busy = false; ui.note = { ok: false, text: /** @type {{ message?: string }} */ (e)?.message ?? 'Action failed.' }; m.redraw(); + focusAfterRender(msg.type === 'learned/clear' ? 'confirm-all' : 'confirm', /** @type {any} */ (msg).host ?? null); return null; }); }, @@ -102,21 +121,25 @@ export const LearnedOriginsView = { const vnode = { state: ui, attrs }; const rows = ui.origins; - return m('div', [ - m('h3', 'Sites peerd thinks you have an account on'), - m('p', 'peerd learns these from ordinary use, and a helper that browses the ' - + 'open web is not allowed onto them - it hands the work to a helper bound to ' - + 'that one site instead. Remove a site here if the guess was wrong.'), - m('p.hint', 'Removing a site does not make it unprotected: a site on peerd’s ' + return m('div.learned-sites', [ + m('h3', { tabindex: -1, 'data-learned-role': 'heading' }, 'Hosts that may share your browser session'), + m('p', 'peerd learns these from ordinary use. A helper that browses the open web ' + + 'is not allowed onto them. It hands the work to a helper bound to the exact ' + + 'site instead. Remove a host here if the guess was wrong.'), + m('p.hint', 'Each entry covers this host on every port and its subdomains.'), + m('p.hint', 'Removing a host does not make it unprotected: a site on peerd’s ' + 'built-in shared-document list, or one you have stored a key for, stays ' - + 'protected either way. peerd can also learn the same site again next time ' + + 'protected either way. peerd can also learn the same host again next time ' + 'it reads a sign-in form there.'), // `p.key-msg.ok` / `.err` — the same banner DenylistView and HooksView use. // (A bare `.ok` has no rule in either stylesheet this page links, so the // success case rendered as ordinary body text, indistinguishable from the // explanatory copy above it.) - ui.note ? m(`p.key-msg${ui.note.ok ? '.ok' : '.err'}`, ui.note.text) : null, + ui.note ? m(`p.key-msg${ui.note.ok ? '.ok' : '.err'}`, { + role: ui.note.ok ? 'status' : 'alert', + 'aria-live': ui.note.ok ? 'polite' : 'assertive', + }, ui.note.text) : null, rows === null ? m('p.muted', 'Loading…') @@ -130,36 +153,49 @@ export const LearnedOriginsView = { // why no keys on the rows: keyed and unkeyed siblings cannot be // mixed — Mithril throws on the mix, and during development that // threw mid-redraw and stranded the whole block on "Loading…" while - // the SW already held two learned origins. The list is re-fetched + // the SW already held two learned hosts. The list is re-fetched // wholesale after every mutation, so keys would buy nothing here. - m('div', rows.map(({ origin, reason }) => m('div', { + m('div', rows.map(({ host, reason }) => m('div', { style: 'display:flex; align-items:center; gap:8px; padding:6px 0; flex-wrap:wrap;', }, [ m('span', { style: 'flex:1; min-width:220px;' }, [ - m('code', origin), + m('code', host), m('span.hint', { style: 'display:block;' }, REASON_COPY[/** @type {keyof REASON_COPY} */ (reason)] ?? reason), ]), - ui.confirm === origin + ui.confirm === host ? m('span', { style: 'display:flex; gap:6px; align-items:center;' }, [ - m('span.hint', 'Stop treating this as yours?'), + m('span.hint', 'Remove this learned host?'), m('button.secondary', { type: 'button', disabled: ui.busy, + 'data-learned-role': 'confirm', + 'data-learned-host': host, + 'aria-label': `Confirm removal of learned host ${host}`, onclick: () => LearnedOriginsView.act( - vnode, { type: 'learned/forget', origin }, `Removed ${origin}.`, + vnode, { type: 'learned/forget', host }, `Removed ${host}.`, ), }, ui.busy ? '…' : 'Remove'), m('button.secondary', { type: 'button', disabled: ui.busy, - onclick: () => { ui.confirm = null; m.redraw(); }, + 'aria-label': `Keep learned host ${host}`, + onclick: () => { + ui.confirm = null; m.redraw(); + focusAfterRender('trigger', host); + }, }, 'Keep'), ]) : m('button.secondary', { type: 'button', disabled: ui.busy, - onclick: () => { ui.confirm = origin; ui.note = null; m.redraw(); }, + 'data-learned-role': 'trigger', + 'data-learned-host': host, + 'aria-label': `Remove learned host ${host}`, + onclick: () => { + ui.confirm = host; ui.note = null; m.redraw(); + focusAfterRender('confirm', host); + }, }, 'Remove'), ]))), m('div', { style: 'margin-top:12px;' }, [ @@ -169,6 +205,8 @@ export const LearnedOriginsView = { m('button.secondary', { type: 'button', disabled: ui.busy, + 'data-learned-role': 'confirm-all', + 'aria-label': 'Confirm forgetting all learned hosts', onclick: () => LearnedOriginsView.act( vnode, { type: 'learned/clear' }, // Report what it actually forgot: "cleared" alone cannot be @@ -179,13 +217,20 @@ export const LearnedOriginsView = { m('button.secondary', { type: 'button', disabled: ui.busy, - onclick: () => { ui.confirmAll = false; m.redraw(); }, + onclick: () => { + ui.confirmAll = false; m.redraw(); + focusAfterRender('trigger-all'); + }, }, 'Cancel'), ]) : m('button.secondary', { type: 'button', disabled: ui.busy, - onclick: () => { ui.confirmAll = true; ui.note = null; m.redraw(); }, + 'data-learned-role': 'trigger-all', + onclick: () => { + ui.confirmAll = true; ui.note = null; m.redraw(); + focusAfterRender('confirm-all'); + }, }, 'Forget all'), ]), ]), diff --git a/extension/tests/unit/options/activity-origin-events.test.js b/extension/tests/unit/options/activity-origin-events.test.js index 2d94e6d6..03a53a54 100644 --- a/extension/tests/unit/options/activity-origin-events.test.js +++ b/extension/tests/unit/options/activity-origin-events.test.js @@ -25,16 +25,16 @@ const mount = (entries) => { const settle = () => new Promise((r) => setTimeout(r, 0)).then(() => m.redraw.sync?.() ?? m.redraw()); describe('options.activity — origin-lock events', () => { - it('labels a learned origin and names the site', async () => { + it('labels a learned host and names its protection scope', async () => { const { root, unmount } = mount([ - { id: '1', when: 1, type: 'origin_learned_sensitive', details: { origin: 'https://bank.test', reason: 'password-field' } }, + { id: '1', when: 1, type: 'origin_learned_sensitive', details: { host: 'bank.test', reason: 'password-field' } }, ]); try { await settle(); const text = root.textContent ?? ''; expect(text.includes('origin_learned_sensitive')).toBe(false); // no raw slug - expect(text.includes('site treated as yours')).toBe(true); - expect(text.includes('https://bank.test')).toBe(true); // WHICH site + expect(text.includes('host may share browser session')).toBe(true); + expect(text.includes('bank.test')).toBe(true); // WHICH host expect(text.includes('password-field')).toBe(true); // and why } finally { unmount(); } }); @@ -56,13 +56,13 @@ describe('options.activity — origin-lock events', () => { // Removing a protection is the noisier event - same posture as the denylist // rows, where disabling a built-in pattern is a warn. const { root, unmount } = mount([ - { id: '3', when: 3, type: 'origin_unlearned_sensitive', details: { origin: 'https://shop.test' } }, + { id: '3', when: 3, type: 'origin_unlearned_sensitive', details: { host: 'shop.test' } }, ]); try { await settle(); const text = root.textContent ?? ''; - expect(text.includes('site no longer yours')).toBe(true); - expect(text.includes('https://shop.test')).toBe(true); + expect(text.includes('learned host removed')).toBe(true); + expect(text.includes('shop.test')).toBe(true); } finally { unmount(); } }); diff --git a/extension/tests/unit/sidepanel/learned-origins-view.test.js b/extension/tests/unit/sidepanel/learned-origins-view.test.js index 518b3ce4..af480b02 100644 --- a/extension/tests/unit/sidepanel/learned-origins-view.test.js +++ b/extension/tests/unit/sidepanel/learned-origins-view.test.js @@ -17,8 +17,8 @@ import { describe, it, expect } from '../../framework.js'; import { LearnedOriginsView } from '/sidepanel/components/learned-origins-view.js'; const ORIGINS = [ - { origin: 'https://acme.test', reason: 'password-field' }, - { origin: 'https://globex.test', reason: 'confirmed-write' }, + { host: 'acme.test', reason: 'password-field' }, + { host: 'globex.test', reason: 'confirmed-write' }, ]; /** @@ -42,19 +42,24 @@ const mount = (reply = () => ({ ok: true, origins: ORIGINS })) => { /** Let the mounted component's pending promise + redraw settle. */ const settle = () => new Promise((r) => setTimeout(r, 0)).then(() => m.redraw.sync?.() ?? m.redraw()); +/** Let focus restoration scheduled for the next painted frame complete. */ +const settleFocus = () => settle().then(() => new Promise(requestAnimationFrame)); + /** @param {HTMLElement} root @param {string} label */ const buttons = (root, label) => [...root.querySelectorAll('button')] .filter((b) => (b.textContent ?? '').trim() === label); describe('sidepanel.learned-origins view', () => { - it('lists every learned origin with a plain-language reason', async () => { + it('lists every learned host with its scope and a plain-language reason', async () => { const { root, sent, unmount } = mount(); try { await settle(); expect(sent[0].type).toBe('learned/list'); const text = root.textContent ?? ''; - expect(text.includes('https://acme.test')).toBe(true); - expect(text.includes('https://globex.test')).toBe(true); + expect(text.includes('acme.test')).toBe(true); + expect(text.includes('globex.test')).toBe(true); + expect(text.includes('may share your browser session')).toBe(true); + expect(text.includes('every port and its subdomains')).toBe(true); // The reason is why the site is treated as theirs — the raw enum would // leave a user with no way to judge whether the guess was wrong. expect(text.includes('a sign-in form was on a page peerd read')).toBe(true); @@ -77,14 +82,16 @@ describe('sidepanel.learned-origins view', () => { try { await settle(); buttons(root, 'Remove')[0].click(); - await settle(); - expect((root.textContent ?? '').includes('Stop treating this as yours?')).toBe(true); + await settleFocus(); + expect((root.textContent ?? '').includes('Remove this learned host?')).toBe(true); + expect(document.activeElement?.getAttribute('data-learned-role')).toBe('confirm'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Confirm removal of learned host acme.test'); // Still only the initial list call: nothing was un-learned by arming. expect(sent.filter((s) => s.type === 'learned/forget').length).toBe(0); } finally { unmount(); } }); - it('confirming Remove sends learned/forget for THAT row’s origin', async () => { + it('confirming Remove sends learned/forget for that row host', async () => { const { root, sent, unmount } = mount(); try { await settle(); @@ -94,7 +101,7 @@ describe('sidepanel.learned-origins view', () => { await settle(); const forget = sent.find((s) => s.type === 'learned/forget'); expect(!!forget).toBe(true); - expect(forget.origin).toBe('https://acme.test'); + expect(forget.host).toBe('acme.test'); // And it re-reads the list rather than trusting a local edit. expect(sent.filter((s) => s.type === 'learned/list').length).toBe(2); } finally { unmount(); } @@ -107,8 +114,10 @@ describe('sidepanel.learned-origins view', () => { buttons(root, 'Remove')[0].click(); await settle(); buttons(root, 'Keep')[0].click(); - await settle(); - expect((root.textContent ?? '').includes('Stop treating this as yours?')).toBe(false); + await settleFocus(); + expect((root.textContent ?? '').includes('Remove this learned host?')).toBe(false); + expect(document.activeElement?.getAttribute('data-learned-role')).toBe('trigger'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Remove learned host acme.test'); expect(sent.filter((s) => s.type === 'learned/forget').length).toBe(0); } finally { unmount(); } }); @@ -118,8 +127,10 @@ describe('sidepanel.learned-origins view', () => { try { await settle(); buttons(root, 'Forget all')[0].click(); // arm - await settle(); + await settleFocus(); expect((root.textContent ?? '').includes('learned sites?')).toBe(true); + expect(document.activeElement?.getAttribute('data-learned-role')).toBe('confirm-all'); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Confirm forgetting all learned hosts'); expect(sent.filter((s) => s.type === 'learned/clear').length).toBe(0); buttons(root, 'Forget all')[0].click(); // confirm await settle(); @@ -134,10 +145,11 @@ describe('sidepanel.learned-origins view', () => { // message saying it was gone. /** @type {string[]} */ const sentTypes = []; + let listCalls = 0; const { root, unmount } = mount((msg) => { sentTypes.push(msg.type); return msg.type === 'learned/list' - ? { ok: true, origins: ORIGINS } + ? { ok: true, origins: (listCalls++ === 0) ? ORIGINS : ORIGINS.slice(1) } : { ok: false, error: 'not-learned' }; }); try { @@ -145,13 +157,14 @@ describe('sidepanel.learned-origins view', () => { buttons(root, 'Remove')[0].click(); await settle(); buttons(root, 'Remove')[0].click(); - await settle(); + await settleFocus(); const text = root.textContent ?? ''; expect(text.includes('already removed somewhere else')).toBe(true); // It re-read the list (2 list calls: mount + after the stale reply)... expect(sentTypes.filter((t) => t === 'learned/list').length).toBe(2); // ...and the armed confirm is gone rather than left hanging. - expect(text.includes('Stop treating this as yours?')).toBe(false); + expect(text.includes('Remove this learned host?')).toBe(false); + expect(document.activeElement?.getAttribute('data-learned-host')).toBe('globex.test'); } finally { unmount(); } }); @@ -171,11 +184,13 @@ describe('sidepanel.learned-origins view', () => { buttons(root, 'Remove')[0].click(); await settle(); buttons(root, 'Remove')[0].click(); - await settle(); + await settleFocus(); const text = root.textContent ?? ''; expect(text.includes('invalid-origin')).toBe(true); + expect(root.querySelector('[role="alert"]')?.textContent).toBe('invalid-origin'); + expect(document.activeElement?.getAttribute('data-learned-role')).toBe('confirm'); expect(sentTypes.filter((t) => t === 'learned/list').length).toBe(1); - expect(text.includes('Stop treating this as yours?')).toBe(true); + expect(text.includes('Remove this learned host?')).toBe(true); } finally { unmount(); } }); @@ -188,8 +203,10 @@ describe('sidepanel.learned-origins view', () => { buttons(root, 'Forget all')[0].click(); await settle(); buttons(root, 'Forget all')[0].click(); - await settle(); + await settleFocus(); expect((root.textContent ?? '').includes('Forgot 2 learned sites.')).toBe(true); + expect(root.querySelector('[role="status"]')?.textContent).toBe('Forgot 2 learned sites.'); + expect(document.activeElement?.getAttribute('data-learned-role')).toBe('heading'); } finally { unmount(); } }); diff --git a/scripts/cdp/GALLERY.md b/scripts/cdp/GALLERY.md index 374971a0..6a18d406 100644 --- a/scripts/cdp/GALLERY.md +++ b/scripts/cdp/GALLERY.md @@ -1,6 +1,6 @@ # peerd — visual gallery -**26 states · 52 screens · baselines `linux-x64`** +**27 states · 54 screens · baselines `linux-x64`** Every screen below is the live UI rendered through the E2E harness at a pinned Chrome build and viewport — the exact images the visual-regression gate compares @@ -189,7 +189,15 @@ against. | --- | --- | | options-dweb-stop-failed (light) | options-dweb-stop-failed (dark) | -### 23 · options-transfer +### 23 · options-learned-sites + +`options-learned-sites` + +| light | dark | +| --- | --- | +| options-learned-sites (light) | options-learned-sites (dark) | + +### 24 · options-transfer `options-transfer` @@ -197,7 +205,7 @@ against. | --- | --- | | options-transfer (light) | options-transfer (dark) | -### 24 · options-transfer-conflict +### 25 · options-transfer-conflict `options-transfer-conflict` @@ -205,7 +213,7 @@ against. | --- | --- | | options-transfer-conflict (light) | options-transfer-conflict (dark) | -### 25 · site-client-confirm +### 26 · site-client-confirm `site-client-confirm` @@ -213,7 +221,7 @@ against. | --- | --- | | site-client-confirm (light) | site-client-confirm (dark) | -### 26 · vm-tab-failed +### 27 · vm-tab-failed `vm-tab-failed` diff --git a/scripts/cdp/baselines/linux-x64/options-learned-sites.dark.png b/scripts/cdp/baselines/linux-x64/options-learned-sites.dark.png new file mode 100644 index 00000000..f79d406b Binary files /dev/null and b/scripts/cdp/baselines/linux-x64/options-learned-sites.dark.png differ diff --git a/scripts/cdp/baselines/linux-x64/options-learned-sites.light.png b/scripts/cdp/baselines/linux-x64/options-learned-sites.light.png new file mode 100644 index 00000000..5e52d65c Binary files /dev/null and b/scripts/cdp/baselines/linux-x64/options-learned-sites.light.png differ diff --git a/scripts/cdp/states.mjs b/scripts/cdp/states.mjs index d5e11890..972bcfc0 100644 --- a/scripts/cdp/states.mjs +++ b/scripts/cdp/states.mjs @@ -1539,6 +1539,21 @@ export const STATES = [ }); rec.check('the browser probe seeded the production learned-origin policy', seeded?.learned === true, JSON.stringify(seeded)); + const alternatePort = await rpc(ctx.page, { + type: 'debug/originLock', origin: 'https://acct.peerd.test:9443', + }); + const descendant = await rpc(ctx.page, { + type: 'debug/originLock', origin: 'https://child.acct.peerd.test', + }); + const sibling = await rpc(ctx.page, { + type: 'debug/originLock', origin: 'https://other.peerd.test', + }); + rec.check('Chrome applies a learned host across schemes and ports', + alternatePort?.learned === true, JSON.stringify(alternatePort)); + rec.check('Chrome applies a learned parent host to descendants', + descendant?.learned === true, JSON.stringify(descendant)); + rec.check('Chrome does not spread a learned host to siblings', + sibling?.learned === false, JSON.stringify(sibling)); const loginTabId = await evalIn(ctx.page, `(async () => { const browser = (await import('/vendor/browser-polyfill.js')).default; const tab = await browser.tabs.create({ active: false, url: ${JSON.stringify(`${accountOrigin}/login`)} }); @@ -2843,6 +2858,56 @@ export const STATES = [ } finally { try { page.close(); } catch { /* */ } } }, }, + { + name: 'options-learned-sites', kind: 'visual', phase: 'post-unlock', + responder: null, + async run(ctx, rec) { + let priorEntries = null; + let page; + try { + const prior = await rpc(ctx.page, { type: 'learned/list' }); + if (!prior?.ok || !Array.isArray(prior.origins)) { + throw new Error(`could not snapshot learned hosts: ${prior?.error ?? 'unknown error'}`); + } + priorEntries = prior.origins; + await rpc(ctx.page, { type: 'learned/clear' }); + await rpc(ctx.page, { type: 'settings/update', patch: { devMode: true } }); + await rpc(ctx.page, { + type: 'debug/originLock', + origin: 'https://accounts.acme.test:8443', + seedReason: 'password-field', + }); + await rpc(ctx.page, { + type: 'debug/originLock', + origin: 'http://portal.globex.test:9080', + seedReason: 'confirmed-write', + }); + await rpc(ctx.page, { type: 'settings/update', patch: { devMode: false } }); + + page = await openWidePage(ctx, 'options/options.html#!/learned-sites'); + await waitFor(() => evalIn(page, `(() => { + const text = document.body.innerText; + return text.includes('accounts.acme.test') + && text.includes('portal.globex.test') + && text.includes('every port and its subdomains'); + })()`), { budgetMs: 15_000, pollMs: 80 }); + await rec.visualPage('options-learned-sites', page); + } finally { + await rpc(ctx.page, { type: 'settings/update', patch: { devMode: false } }).catch(() => {}); + if (priorEntries) { + await rpc(ctx.page, { type: 'learned/clear' }).catch(() => {}); + await rpc(ctx.page, { type: 'settings/update', patch: { devMode: true } }).catch(() => {}); + for (const { host, reason } of priorEntries) { + await rpc(ctx.page, { + type: 'debug/originLock', origin: `https://${host}`, seedReason: reason, + }).catch(() => {}); + } + await rpc(ctx.page, { type: 'settings/update', patch: { devMode: false } }).catch(() => {}); + } + try { page?.close(); } catch { /* */ } + } + }, + }, { name: 'options-contributor-metrics', kind: 'visual', phase: 'post-unlock', responder: null, diff --git a/scripts/firefox/run-runtime-tests.mjs b/scripts/firefox/run-runtime-tests.mjs index 8c6e4084..2f40aac4 100644 --- a/scripts/firefox/run-runtime-tests.mjs +++ b/scripts/firefox/run-runtime-tests.mjs @@ -1142,11 +1142,28 @@ const runNumericTabAuthoritySmoke = async (driver, providerServer) => { const lock = await browser.runtime.sendMessage({ type: 'debug/originLock', origin: fixtureOrigin, seedReason: 'password-field', }); + const alternatePort = await browser.runtime.sendMessage({ + type: 'debug/originLock', origin: 'https://${DNR_PUBLIC_HOST}:9443', + }); + const descendant = await browser.runtime.sendMessage({ + type: 'debug/originLock', origin: 'https://child.${DNR_PUBLIC_HOST}', + }); + const sibling = await browser.runtime.sendMessage({ + type: 'debug/originLock', origin: 'https://other.test', + }); const tab = await browser.tabs.create({ url: fixtureUrl, active: false }); for (let attempt = 0; attempt < 200; attempt += 1) { const live = await browser.tabs.get(tab.id).catch(() => null); if (live?.url === fixtureUrl) { - return { settingsOk: settings?.ok === true, lock, tabId: tab.id, url: live.url }; + return { + settingsOk: settings?.ok === true, + lock, + alternatePort, + descendant, + sibling, + tabId: tab.id, + url: live.url, + }; } await new Promise((resolveWait) => setTimeout(resolveWait, 25)); } @@ -1160,6 +1177,12 @@ const runNumericTabAuthoritySmoke = async (driver, providerServer) => { && initialized?.url === fixtureUrl, 'Firefox prepares a live tab whose origin has a learned sensitive signal', JSON.stringify(initialized)); + assert(initialized?.alternatePort?.learned === true, + 'Firefox applies a learned host across schemes and ports', JSON.stringify(initialized)); + assert(initialized?.descendant?.learned === true, + 'Firefox applies a learned parent host to descendants', JSON.stringify(initialized)); + assert(initialized?.sibling?.learned === false, + 'Firefox does not spread a learned host to siblings', JSON.stringify(initialized)); providerServer.setScenario({ mode: 'numeric-tab-authority', actorTarget: String(fixtureTabId) }); const started = await driver.executeAsync(` diff --git a/tests/background/routes-learned-origins.test.ts b/tests/background/routes-learned-origins.test.ts index f6c94cc7..1b682815 100644 --- a/tests/background/routes-learned-origins.test.ts +++ b/tests/background/routes-learned-origins.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect } from 'bun:test'; import { makeLearnedOriginRoutes } from '../../extension/background/routes/learned-origins.js'; -// The settings view of the learned origin set, and the only un-learn path. What -// these pin: the origin is CANONICALIZED with the same normalizer `note` uses (a +// The settings view of the learned host set, and the only un-learn path. What +// these pin: the host is CANONICALIZED with the same normalizer `note` uses (a // mismatch would silently no-op and read as a broken button), a removal is not // reported until it is durable, and the routes never audit themselves (the // store's onForget hook owns that, or every removal is recorded twice). @@ -16,8 +16,8 @@ const makeDeps = (learned: Record = {}, over: any = {}) => { // at the bottom); a stand-in that omits it is not the real interface. hydrate: async () => {}, entries: () => [...map.entries()] - .map(([origin, reason]) => ({ origin, reason })) - .sort((a, b) => a.origin.localeCompare(b.origin)), + .map(([host, reason]) => ({ host, reason })) + .sort((a, b) => a.host.localeCompare(b.host)), forget: (o: string) => map.delete(o), clear: () => { const n = map.size; map.clear(); return n; }, settled: async () => { settledCalls.push(map.size); }, @@ -26,7 +26,7 @@ const makeDeps = (learned: Record = {}, over: any = {}) => { // Stands in for the SW's normalizeApiOrigin: canonical origin or null. normalizeApiOrigin: (raw: unknown) => { if (typeof raw !== 'string' || !raw) return null; - try { return new URL(raw).origin; } catch { return null; } + try { return new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).origin; } catch { return null; } }, ...over, }; @@ -35,12 +35,12 @@ const makeDeps = (learned: Record = {}, over: any = {}) => { describe('learned/list', () => { test('returns the sorted set', async () => { - const { deps } = makeDeps({ 'https://b.test': 'password-field', 'https://a.test': 'confirmed-write' }); + const { deps } = makeDeps({ 'b.test': 'password-field', 'a.test': 'confirmed-write' }); expect(await makeLearnedOriginRoutes(deps)['learned/list']()).toEqual({ ok: true, origins: [ - { origin: 'https://a.test', reason: 'confirmed-write' }, - { origin: 'https://b.test', reason: 'password-field' }, + { host: 'a.test', reason: 'confirmed-write' }, + { host: 'b.test', reason: 'password-field' }, ], }); }); @@ -53,25 +53,25 @@ describe('learned/list', () => { describe('learned/forget', () => { test('canonicalizes the origin before deleting', async () => { - // The UI renders `https://app.test`; a caller passing a URL with a path (or a - // trailing slash) must still hit the same key the classifier looks up. - const { deps, map } = makeDeps({ 'https://app.test': 'password-field' }); - const r = await makeLearnedOriginRoutes(deps)['learned/forget']({ origin: 'https://app.test/login?x=1' }); + // The UI renders `app.test`; a caller passing an old origin-shaped value + // must still hit the same host key the classifier looks up. + const { deps, map } = makeDeps({ 'app.test': 'password-field' }); + const r = await makeLearnedOriginRoutes(deps)['learned/forget']({ host: 'https://app.test:8443/login?x=1' }); expect(r.ok).toBe(true); - expect(map.has('https://app.test')).toBe(false); + expect(map.has('app.test')).toBe(false); }); test('refuses a non-origin instead of no-oping silently', async () => { - const { deps } = makeDeps({ 'https://app.test': 'password-field' }); + const { deps } = makeDeps({ 'app.test': 'password-field' }); for (const bad of ['', 'not a url', null, undefined, 42]) { - expect(await makeLearnedOriginRoutes(deps)['learned/forget']({ origin: bad })) + expect(await makeLearnedOriginRoutes(deps)['learned/forget']({ host: bad })) .toEqual({ ok: false, error: 'invalid-origin' }); } }); test('reports not-learned for an origin that was never in the set', async () => { - const { deps } = makeDeps({ 'https://app.test': 'password-field' }); - expect(await makeLearnedOriginRoutes(deps)['learned/forget']({ origin: 'https://other.test' })) + const { deps } = makeDeps({ 'app.test': 'password-field' }); + expect(await makeLearnedOriginRoutes(deps)['learned/forget']({ host: 'other.test' })) .toEqual({ ok: false, error: 'not-learned' }); }); @@ -79,7 +79,7 @@ describe('learned/forget', () => { // The caller re-renders from this reply. Replying early would show a row gone // that a mid-flight service-worker eviction could still bring back. const order: string[] = []; - const { deps } = makeDeps({ 'https://app.test': 'password-field' }, { + const { deps } = makeDeps({ 'app.test': 'password-field' }, { learnedOrigins: { hydrate: async () => { order.push('hydrate'); }, entries: () => { order.push('entries'); return []; }, @@ -88,23 +88,23 @@ describe('learned/forget', () => { settled: async () => { order.push('settled'); }, }, }); - await makeLearnedOriginRoutes(deps)['learned/forget']({ origin: 'https://app.test' }); + await makeLearnedOriginRoutes(deps)['learned/forget']({ host: 'app.test' }); expect(order).toEqual(['hydrate', 'forget', 'settled', 'entries']); }); test('does not audit — the store hook owns that, so a removal is recorded once', async () => { const audits: any[] = []; - const { deps } = makeDeps({ 'https://app.test': 'password-field' }, { + const { deps } = makeDeps({ 'app.test': 'password-field' }, { auditLog: { append: async (e: any) => { audits.push(e); } }, }); - await makeLearnedOriginRoutes(deps)['learned/forget']({ origin: 'https://app.test' }); + await makeLearnedOriginRoutes(deps)['learned/forget']({ host: 'app.test' }); expect(audits).toEqual([]); }); }); describe('learned/clear', () => { test('empties the set and reports how many it forgot', async () => { - const { deps, map } = makeDeps({ 'https://a.test': 'password-field', 'https://b.test': 'confirmed-write' }); + const { deps, map } = makeDeps({ 'a.test': 'password-field', 'b.test': 'confirmed-write' }); const r = await makeLearnedOriginRoutes(deps)['learned/clear'](); expect(r).toEqual({ ok: true, origins: [], forgotten: 2 }); expect(map.size).toBe(0); @@ -125,7 +125,7 @@ describe('learned/clear', () => { // seam BETWEEN them: the SW kicks learnedOrigins.hydrate() at boot without // awaiting it, and a settings message is exactly what wakes a cold worker. Before // the routes awaited hydration, `learned/list` reported an empty list for a -// profile full of learned origins and `learned/clear` reported success while +// profile full of learned hosts and `learned/clear` reported success while // forgetting nothing — the boot read then restored everything. import { makeLearnedOrigins } from '../../extension/peerd-runtime/actor/learned-origins.js'; @@ -148,7 +148,7 @@ const routesFor = (store: any) => makeLearnedOriginRoutes({ learnedOrigins: store, normalizeApiOrigin: (raw: unknown) => { if (typeof raw !== 'string' || !raw) return null; - try { return new URL(raw).origin; } catch { return null; } + try { return new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).origin; } catch { return null; } }, }); @@ -157,7 +157,7 @@ describe('routes wait for the durable set', () => { const { store, release } = coldStore({ 'https://a.test': 'password-field' }); const pending = routesFor(store)['learned/list'](); release(null); - expect(await pending).toEqual({ ok: true, origins: [{ origin: 'https://a.test', reason: 'password-field' }] }); + expect(await pending).toEqual({ ok: true, origins: [{ host: 'a.test', reason: 'password-field' }] }); }); test('learned/clear on a cold worker forgets what is really there, and says how many', async () => { @@ -178,11 +178,11 @@ describe('routes wait for the durable set', () => { test('learned/forget on a cold worker removes the row instead of reporting not-learned', async () => { const { store, release, booting } = coldStore({ 'https://a.test': 'password-field' }); - const pending = routesFor(store)['learned/forget']({ origin: 'https://a.test' }); + const pending = routesFor(store)['learned/forget']({ host: 'a.test' }); release(null); expect((await pending).ok).toBe(true); await booting; - expect(store.snapshot().has('https://a.test')).toBe(false); + expect(store.snapshot().has('a.test')).toBe(false); }); test('the boot read is shared, not re-run per route call', async () => { diff --git a/tests/peerd-runtime/actor/learned-origins.test.ts b/tests/peerd-runtime/actor/learned-origins.test.ts index 3b815cec..d95aa9f5 100644 --- a/tests/peerd-runtime/actor/learned-origins.test.ts +++ b/tests/peerd-runtime/actor/learned-origins.test.ts @@ -33,7 +33,7 @@ describe('learning', () => { const { store } = harness(); await store.hydrate(); expect(store.note('https://app.test', 'password-field')).toBe(true); - expect(store.snapshot().get('https://app.test')).toBe('password-field'); + expect(store.snapshot().get('app.test')).toBe('password-field'); }); test('the FIRST reason sticks', async () => { @@ -44,7 +44,7 @@ describe('learning', () => { await store.hydrate(); store.note('https://app.test', 'password-field'); expect(store.note('https://app.test', 'confirmed-write')).toBe(false); - expect(store.snapshot().get('https://app.test')).toBe('password-field'); + expect(store.snapshot().get('app.test')).toBe('password-field'); }); test('onLearn fires once per origin, never on a repeat', async () => { @@ -52,7 +52,17 @@ describe('learning', () => { await store.hydrate(); store.note('https://app.test', 'password-field'); store.note('https://app.test', 'password-field'); - expect(learns).toEqual([['https://app.test', 'password-field']]); + expect(learns).toEqual([['app.test', 'password-field']]); + }); + + test('scheme and port variants share one host record and one audit event', async () => { + const { store, learns } = harness(); + await store.hydrate(); + expect(store.note('https://app.test:8443', 'password-field')).toBe(true); + expect(store.note('http://app.test:8080', 'confirmed-write')).toBe(false); + expect(store.note('app.test', 'confirmed-write')).toBe(false); + expect(store.entries()).toEqual([{ host: 'app.test', reason: 'password-field' }]); + expect(learns).toEqual([['app.test', 'password-field']]); }); test('an unknown reason is refused', async () => { @@ -79,7 +89,7 @@ describe('persistence', () => { test('a stored set is restored', async () => { const { store } = harness({ 'https://app.test': 'confirmed-write' }); await store.hydrate(); - expect(store.snapshot().get('https://app.test')).toBe('confirmed-write'); + expect(store.snapshot().get('app.test')).toBe('confirmed-write'); expect(store.hydrationStatus()).toEqual({ ready: true, ok: true }); }); @@ -112,7 +122,7 @@ describe('persistence', () => { await store.hydrate(); store.note('https://app.test', 'password-field'); await store.settled(); - expect(store.snapshot().get('https://app.test')).toBe('password-field'); + expect(store.snapshot().get('app.test')).toBe('password-field'); }); test('each save is the WHOLE set, so a partial write cannot lose an origin', async () => { @@ -121,7 +131,39 @@ describe('persistence', () => { store.note('https://a.test', 'password-field'); store.note('https://b.test', 'confirmed-write'); await store.settled(); - expect(saves.at(-1)).toEqual({ 'https://a.test': 'password-field', 'https://b.test': 'confirmed-write' }); + expect(saves.at(-1)).toEqual({ 'a.test': 'password-field', 'b.test': 'confirmed-write' }); + }); + + test('legacy origin keys migrate and duplicate ports collapse on hydrate', async () => { + const { store, saves } = harness({ + 'https://app.test:8443': 'password-field', + 'http://app.test:8080': 'confirmed-write', + 'https://other.test': 'confirmed-write', + }); + await store.hydrate(); + await store.settled(); + expect(store.entries()).toEqual([ + { host: 'app.test', reason: 'password-field' }, + { host: 'other.test', reason: 'confirmed-write' }, + ]); + expect(saves.at(-1)).toEqual({ + 'app.test': 'password-field', + 'other.test': 'confirmed-write', + }); + }); + + test('oversized durable state is capped and compacted on hydrate', async () => { + const stored = Object.fromEntries(Array.from( + { length: MAX_LEARNED + 25 }, + (_, index) => [`stored-${index}.test`, 'password-field'], + )); + const { store, saves } = harness(stored); + await store.hydrate(); + await store.settled(); + expect(store.size()).toBe(MAX_LEARNED); + expect(Object.keys(saves.at(-1) ?? {})).toHaveLength(MAX_LEARNED); + expect(saves.at(-1)?.['stored-0.test']).toBe('password-field'); + expect(saves.at(-1)?.[`stored-${MAX_LEARNED}.test`]).toBeUndefined(); }); }); @@ -136,9 +178,19 @@ describe('the cap', () => { expect(store.size()).toBe(MAX_LEARNED); expect(store.note('https://one-too-many.test', 'password-field')).toBe(false); // The very first origin is still there — nothing was traded away for it. - expect(store.snapshot().get('https://s0.test')).toBe('password-field'); + expect(store.snapshot().get('s0.test')).toBe('password-field'); expect(store.size()).toBe(MAX_LEARNED); }); + + test('port spelling cannot fill the cap', async () => { + const { store } = harness(); + await store.hydrate(); + for (let port = 1; port <= MAX_LEARNED; port += 1) { + store.note(`https://same.test:${port}`, 'password-field'); + } + expect(store.size()).toBe(1); + expect(store.note('https://another.test', 'password-field')).toBe(true); + }); }); describe('the boot read races the first page walk', () => { @@ -160,12 +212,12 @@ describe('the boot read races the first page walk', () => { await hydrating; await store.settled(); // Both survive. - expect(store.snapshot().get('https://live.test')).toBe('password-field'); - expect(store.snapshot().get('https://stored.test')).toBe('confirmed-write'); + expect(store.snapshot().get('live.test')).toBe('password-field'); + expect(store.snapshot().get('stored.test')).toBe('confirmed-write'); // And the LAST durable write holds both, not the racing writer's view. expect(saves.at(-1)).toEqual({ - 'https://live.test': 'password-field', - 'https://stored.test': 'confirmed-write', + 'live.test': 'password-field', + 'stored.test': 'confirmed-write', }); }); @@ -181,7 +233,34 @@ describe('the boot read races the first page walk', () => { store.note('https://x.test', 'password-field'); releaseLoad(null); await hydrating; - expect(store.snapshot().get('https://x.test')).toBe('password-field'); + expect(store.snapshot().get('x.test')).toBe('password-field'); + }); + + test('live observations survive when stored entries fill the remaining cap', async () => { + const stored = Object.fromEntries(Array.from( + { length: MAX_LEARNED }, + (_, index) => [`stored-${index}.test`, 'confirmed-write'], + )); + let releaseLoad: (value: Record) => void = () => {}; + const loadGate = new Promise>((resolve) => { releaseLoad = resolve; }); + const saves: Array> = []; + const store = makeLearnedOrigins({ + load: async () => loadGate, + save: async (all) => { saves.push({ ...all }); }, + onError: () => {}, + }); + const hydrating = store.hydrate(); + expect(store.note('https://live-a.test', 'password-field')).toBe(true); + expect(store.note('https://live-b.test', 'confirmed-write')).toBe(true); + releaseLoad(stored); + await hydrating; + await store.settled(); + expect(store.size()).toBe(MAX_LEARNED); + expect(store.snapshot().get('live-a.test')).toBe('password-field'); + expect(store.snapshot().get('live-b.test')).toBe('confirmed-write'); + expect(Object.keys(saves.at(-1) ?? {})).toHaveLength(MAX_LEARNED); + expect(saves.at(-1)?.['live-a.test']).toBe('password-field'); + expect(saves.at(-1)?.['live-b.test']).toBe('confirmed-write'); }); }); @@ -203,7 +282,15 @@ describe('un-learning (user-initiated only)', () => { await store.hydrate(); store.note('https://app.test', 'password-field'); expect(store.forget('https://app.test')).toBe(true); - expect(store.snapshot().has('https://app.test')).toBe(false); + expect(store.snapshot().has('app.test')).toBe(false); + }); + + test('forget accepts another scheme and port spelling of the learned host', async () => { + const { store, forgets } = forgetHarness({ 'https://app.test:8443': 'password-field' }); + await store.hydrate(); + expect(store.forget('http://app.test:8080/path')).toBe(true); + expect(store.size()).toBe(0); + expect(forgets).toEqual([['app.test']]); }); test('forget persists — the removal survives the next boot read', async () => { @@ -220,7 +307,7 @@ describe('un-learning (user-initiated only)', () => { expect(store.forget('https://nope.test')).toBe(false); // never learned expect(store.forget('https://app.test')).toBe(true); expect(store.forget('https://app.test')).toBe(false); // already gone - expect(forgets).toEqual([['https://app.test']]); + expect(forgets).toEqual([['app.test']]); }); test('forget ignores junk rather than throwing', async () => { @@ -240,7 +327,7 @@ describe('un-learning (user-initiated only)', () => { expect(store.clear()).toBe(2); expect(store.size()).toBe(0); expect(store.clear()).toBe(0); // idempotent - expect(forgets).toEqual([['https://a.test', 'https://b.test']]); + expect(forgets).toEqual([['a.test', 'b.test']]); }); // THE RACE THAT MATTERS. hydrate() MERGES (it must, so a signal landing during @@ -260,7 +347,7 @@ describe('un-learning (user-initiated only)', () => { expect(store.forget('https://x.test')).toBe(true); releaseLoad(null); await hydrating; - expect(store.snapshot().has('https://x.test')).toBe(false); + expect(store.snapshot().has('x.test')).toBe(false); }); test('a clear that races the boot read also suppresses rows this heap never saw', async () => { @@ -286,7 +373,7 @@ describe('un-learning (user-initiated only)', () => { // Re-learning is expected: the signal fires again next time peerd reads a // sign-in form there. A lingering tombstone would silently refuse it. expect(store.note('https://app.test', 'password-field')).toBe(true); - expect(store.snapshot().get('https://app.test')).toBe('password-field'); + expect(store.snapshot().get('app.test')).toBe('password-field'); }); }); @@ -299,8 +386,8 @@ describe('entries (the settings list)', () => { }); await store.hydrate(); expect(store.entries()).toEqual([ - { origin: 'https://a.test', reason: 'confirmed-write' }, - { origin: 'https://b.test', reason: 'password-field' }, + { host: 'a.test', reason: 'confirmed-write' }, + { host: 'b.test', reason: 'password-field' }, ]); // Mutating the returned array must not touch the classifier's own state: // this value crosses a message boundary to the settings page. diff --git a/tests/peerd-runtime/actor/origin-lock-report.test.ts b/tests/peerd-runtime/actor/origin-lock-report.test.ts index 444237e9..44fa93f8 100644 --- a/tests/peerd-runtime/actor/origin-lock-report.test.ts +++ b/tests/peerd-runtime/actor/origin-lock-report.test.ts @@ -125,6 +125,18 @@ describe('the report is useful, not just safe', () => { expect(text).toMatch(/should be guessed at|from what the user asked/i); }); + test('a learned-scope handoff does not claim exact identity and forbids spelling retries', () => { + const text = describeLandingStop({ + action: 'handoff', reason: 'r', from: null, + to: 'http://bank.test:9443/x', handoffTo: 'http://bank.test:9443', + }); + expect(text).toContain("may share the user's browser session"); + expect(text).toContain('Do not evade this stop by changing the address'); + expect(text).toContain("comes from the user's request"); + expect(text).toContain('site:http://bank.test:9443'); + expect(text).not.toContain('has an identity on'); + }); + test('an end says both origins and refuses to guess who moved the tab', () => { const text = describeLandingStop({ action: 'end', diff --git a/tests/peerd-runtime/actor/origin-sensitivity.test.ts b/tests/peerd-runtime/actor/origin-sensitivity.test.ts index 8fc8b651..381c87ab 100644 --- a/tests/peerd-runtime/actor/origin-sensitivity.test.ts +++ b/tests/peerd-runtime/actor/origin-sensitivity.test.ts @@ -11,7 +11,10 @@ // - it never throws on junk, because it runs on every landing import { describe, test, expect } from 'bun:test'; -import { classifyOriginSensitivity, sameOrigin, LEARNED_REASONS } from '../../../extension/peerd-runtime/actor/origin-sensitivity.js'; +import { + classifyOriginSensitivity, learnedOriginCovers, sameOrigin, sensitivityHost, + LEARNED_REASONS, +} from '../../../extension/peerd-runtime/actor/origin-sensitivity.js'; import { isKnownIdpHost } from '../../../extension/peerd-runtime/actor/idp-registry.js'; const ugcOnly = { isUgcZone: (o: string) => o === 'https://github.com' }; @@ -92,14 +95,67 @@ describe('sensitivity — the learned set', () => { expect(LEARNED_REASONS).toContain(v.reason as any); }); - test('the learned set is keyed on the NORMALIZED origin', () => { - // The store writes normalized keys; a lookup that didn't normalize would - // silently miss and hand a roaming actor into a credentialed site. + test('legacy normalized-origin keys remain readable during migration', () => { + // Old durable records used origins. The classifier accepts that shape while + // hydration compacts it to a host key. const v = classifyOriginSensitivity('HTTPS://Bank.test:443/login', { learned: new Set(['https://bank.test']), }); expect(v.sensitive).toBe(true); }); + + test('a learned host covers every scheme and port on that host', () => { + const learned = new Map([['login.bank.test', 'password-field' as const]]); + for (const landing of [ + 'https://login.bank.test', + 'https://login.bank.test:8443/account', + 'http://login.bank.test:8080/account', + ]) { + expect(classifyOriginSensitivity(landing, { learned }).sensitive).toBe(true); + } + }); + + test('a learned parent covers descendant hosts', () => { + const learned = new Map([['bank.test', 'confirmed-write' as const]]); + const v = classifyOriginSensitivity('https://private.accounts.bank.test/inbox', { learned }); + expect(v.sensitive).toBe(true); + expect(v.reason).toBe('confirmed-write'); + expect(v.origin).toBe('https://private.accounts.bank.test'); + }); + + test('a learned child does not poison its parent or a sibling', () => { + const learned = new Map([['attacker.shared.test', 'password-field' as const]]); + expect(classifyOriginSensitivity('https://shared.test', { learned }).sensitive).toBe(false); + expect(classifyOriginSensitivity('https://victim.shared.test', { learned }).sensitive).toBe(false); + }); + + test('hostname boundaries prevent suffix lookalikes from matching', () => { + const learned = new Map([['bank.test', 'password-field' as const]]); + expect(classifyOriginSensitivity('https://secure.bank.test', { learned }).sensitive).toBe(true); + expect(classifyOriginSensitivity('https://bank.test.evil.test', { learned }).sensitive).toBe(false); + expect(classifyOriginSensitivity('https://notbank.test', { learned }).sensitive).toBe(false); + }); + + test('the closest learned host supplies the reason', () => { + const learned = new Map([ + ['example.test', 'password-field' as const], + ['accounts.example.test', 'confirmed-write' as const], + ]); + const v = classifyOriginSensitivity('https://private.accounts.example.test', { learned }); + expect(v.reason).toBe('confirmed-write'); + }); +}); + +describe('learned cookie-host scope', () => { + test('normalization removes scheme, port, path, and host casing', () => { + expect(sensitivityHost('HTTP://Login.Bank.test:8080/path')).toBe('login.bank.test'); + }); + + test('coverage is one-way from parent to descendant', () => { + expect(learnedOriginCovers('https://example.test:8443', 'http://a.example.test:8080')).toBe(true); + expect(learnedOriginCovers('https://a.example.test', 'https://example.test')).toBe(false); + expect(learnedOriginCovers('https://a.example.test', 'https://b.example.test')).toBe(false); + }); }); describe('sensitivity — nothing to be signed in to', () => { @@ -130,6 +186,7 @@ describe('sameOrigin', () => { test('different origins are different', () => { expect(sameOrigin('https://github.com', 'https://evil.test')).toBe(false); expect(sameOrigin('https://github.com', 'http://github.com')).toBe(false); // scheme counts + expect(sameOrigin('https://github.com:8443', 'https://github.com:9443')).toBe(false); // port counts }); test('an unusable origin is never "the same as" anything, including another unusable one', () => { diff --git a/tests/red-team/scenarios/10-origin-retasking.ts b/tests/red-team/scenarios/10-origin-retasking.ts index 3eb35afe..d08b3e66 100644 --- a/tests/red-team/scenarios/10-origin-retasking.ts +++ b/tests/red-team/scenarios/10-origin-retasking.ts @@ -154,6 +154,53 @@ const CORPUS: Case[] = [ }; }, }, + { + vector: 'change scheme and port after a host is learned sensitive', + seeks: 'recover roaming authority through another spelling of the same cookie host', + defense: 'learned sensitivity is keyed by hostname rather than origin', + check: () => { + const verdict = classifyOriginSensitivity('http://bank.test:9443/transfer', { + learned: new Map([['bank.test', 'password-field']]), + }); + return { + denied: verdict.sensitive && verdict.reason === 'password-field' + && verdict.origin === 'http://bank.test:9443', + evidence: `sensitive=${verdict.sensitive} reason=${verdict.reason ?? 'none'} origin=${verdict.origin ?? 'none'}`, + }; + }, + }, + { + vector: 'move from a learned parent host onto a cookie-sharing descendant', + seeks: 'recover roaming authority where a Domain cookie may still authenticate the user', + defense: 'a learned parent hostname covers boundary-checked descendants', + check: () => { + const verdict = classifyOriginSensitivity('https://pay.bank.test/transfer', { + learned: new Map([['bank.test', 'confirmed-write']]), + }); + return { + denied: verdict.sensitive && verdict.reason === 'confirmed-write', + evidence: `sensitive=${verdict.sensitive} reason=${verdict.reason ?? 'none'}`, + }; + }, + }, + { + vector: 'learn a hostile child host, then visit its parent, sibling, or suffix lookalike', + seeks: 'poison unrelated account surfaces into persistent false handoffs', + defense: 'child marks do not widen upward or sideways and suffix matching is label-bound', + check: () => { + const learned = new Map([['login.bank.test', 'password-field' as const]]); + const candidates = [ + 'https://bank.test', + 'https://pay.bank.test', + 'https://login.bank.test.evil.test', + ]; + const verdicts = candidates.map((url) => classifyOriginSensitivity(url, { learned }).sensitive); + return { + denied: verdicts.every((sensitiveVerdict) => sensitiveVerdict === false), + evidence: `sensitive=${verdicts.join(',')}`, + }; + }, + }, { vector: 'numerically address a sensitive tab already owned by a legitimate site actor', seeks: 'erase the existing binding and its live origin lock during refusal', @@ -492,6 +539,8 @@ export const scenario: Scenario = { }); return summarize(probes, [ 'origin lock: roaming may not enter a credentialed origin', + 'learned sensitivity follows cookie host scope across scheme, port, and descendants', + 'learned child hosts cannot poison parents, siblings, or suffix lookalikes', 'numeric tab ids identify locations, not signed-in-site authority', 'numeric refusal preserves an existing actor binding and origin lock', 'origin lock: bound may not leave its owned origin',