// Vestibule background script — production implementation. // // Responsibilities (one per section, step-down order): // 1. URL filtering (domain safelist default — engine in url-policy.js) // 2. Session reset (real per-origin cookie preservation) // 3. Idle timeout (polls browser.idle, delegates to resetSession) // 4. Native Messaging bridge (long-lived port to usher) // 5. Unlock popup management (opens/closes popup, routes results) // 6. Admin grace mode (suppresses resets after successful unlock) // 7. Admin wizard command (Ctrl+Shift+V) // 8. Message routing (table-driven dispatch) const NATIVE_HOST = "com.vestibule.usher"; const IDLE_POLL_INTERVAL_MS = 30_000; const RESET_DEBOUNCE_MS = 5_000; const ADMIN_GRACE_MS = 10 * 60 * 1000; // Safe by default: a fresh install blocks every domain except // browser-internal pages until the operator configures a safelist. const DEFAULT_POLICY = { mode: "safelist", safelist: [], allowlist: [], blocklist: [], homeUrl: "about:blank", idleTimeoutS: 300, onReset: "both", onWake: "both", dataPersistenceAllowlist: [], }; let policy = { ...DEFAULT_POLICY }; let lastResetAt = 0; let nativePort = null; let adminGraceUntil = 0; let unlockPopupId = null; // ─── Policy loading ─────────────────────────────────────────────────── browser.storage.local.get("policy").then( (result) => { if (result.policy) policy = { ...DEFAULT_POLICY, ...result.policy }; console.log("[vestibule] policy loaded:", policy); adoptStartupHome(); }, (err) => console.warn("[vestibule] storage read failed:", err) ); browser.storage.onChanged.addListener((changes, area) => { if (area !== "local" || !changes.policy) return; policy = { ...DEFAULT_POLICY, ...changes.policy.newValue }; console.log("[vestibule] policy updated:", policy); }); // First-boot adoption: the provisioner launches the browser with the // kiosk home URL on the command line; the extension cannot read // kiosk.env. While the policy is still the default, the startup page // becomes home and its domain the first safelist entry, so a // provisioned kiosk boots to a working page instead of its own block // page. Only tabs the browser was launched with qualify — decided in // the engine (adoptStartupPolicy), which is unit-tested. function adoptStartupHome() { browser.tabs .query({}) .then((tabs) => { const startupUrls = tabs.map((t) => t.pendingUrl || t.url || ""); const adopted = UrlPolicy.adoptStartupPolicy(startupUrls, policy); if (!adopted) return; policy = adopted; browser.storage.local.set({ policy }).catch(() => {}); console.log( "[vestibule] startup page adopted as home, domain safelisted:", adopted.homeUrl ); }) .catch(() => {}); } // ─── URL filtering (engine in url-policy.js) ───────────────────────── // // url-policy.js is the single source of truth for the decision; this // section owns only the state (policy) and the webRequest wiring. // The home origin is recomputed on every decision so a policy update // takes effect on the very next request. const UrlPolicy = globalThis.VestibuleUrlPolicy; const shouldBlock = (url, mainFrame) => UrlPolicy.shouldBlockRequest(url, policy, { mainFrame, homeHostname: UrlPolicy.homeHostnameOf(policy.homeUrl), }); const blockedPageUrl = (url) => browser.runtime.getURL("blocked.html") + "?u=" + encodeURIComponent(url); browser.webRequest.onBeforeRequest.addListener( (details) => { const mainFrame = details.type === "main_frame"; if (!shouldBlock(details.url, mainFrame)) return {}; console.log("[vestibule] blocked:", details.url); // Top-level navigations land on the block page (a kiosk user // staring at a raw connection error learns nothing); everything // else — subresources, frames, fetches — is cancelled outright. return mainFrame ? { redirectUrl: blockedPageUrl(details.url) } : { cancel: true }; }, { urls: [""] }, ["blocking"] ); // ─── Session reset (real per-origin preservation) ───────────────────── // // When dataPersistenceAllowlist is non-empty: // - Cookies for allowlisted domains are preserved (via getAll + remove) // - All other data types are wiped unconditionally // - localStorage is NOT wiped (WebExtension API cannot enumerate origins // for selective removal; localStorage typically holds UI state, not // auth tokens — the risk is low and documented) // // When allowlist is empty: wipe everything (strict mode). const ALWAYS_WIPE_TYPES = { history: true, cache: true, formData: true, downloads: true, pluginData: true, serviceWorkers: true, passwords: true, sessions: true, indexedDB: true, }; const ALL_TYPES = { ...ALWAYS_WIPE_TYPES, cookies: true, localStorage: true }; function resetSession(reason) { if (isInAdminGrace()) { console.log(`[vestibule] reset (${reason}) suppressed — admin grace active`); return; } if (Date.now() - lastResetAt < RESET_DEBOUNCE_MS) { console.log(`[vestibule] reset (${reason}) debounced`); return; } lastResetAt = Date.now(); console.log(`[vestibule] resetting session (reason: ${reason})`); const allowlist = policy.dataPersistenceAllowlist || []; const wipePromise = allowlist.length === 0 ? wipeAllData() : wipeAllExceptCookies(allowlist); wipePromise .then(() => { console.log("[vestibule] browsing data cleared"); return browser.tabs.query({}); }) .then(navigateAllTabsHome) .then(() => maybeShowLockOverlay(reason)) .catch((err) => console.error("[vestibule] session reset failed:", err)); } function wipeAllData() { return browser.browsingData.remove({}, ALL_TYPES); } function wipeAllExceptCookies(allowlist) { // Wipe everything except cookies (which we handle selectively below) return browser.browsingData .remove({}, ALWAYS_WIPE_TYPES) .then(() => removeNonAllowlistedCookies(allowlist)); } function removeNonAllowlistedCookies(allowlist) { return browser.cookies.getAll({}).then((cookies) => { const toRemove = cookies.filter((c) => !isCookieAllowlisted(c, allowlist)); console.log( `[vestibule] cookies: ${cookies.length} total, ${toRemove.length} to remove, ${cookies.length - toRemove.length} preserved` ); return Promise.all( toRemove.map((c) => { const domain = (c.domain || "").replace(/^\./, ""); const url = `http${c.secure ? "s" : ""}://${domain}${c.path}`; return browser.cookies.remove({ url, name: c.name, storeId: c.storeId }).catch(() => {}); }) ); }); } function isCookieAllowlisted(cookie, allowlist) { const domain = (cookie.domain || "").toLowerCase().replace(/^\./, ""); return allowlist.some((pat) => domain.includes(pat.toLowerCase())); } function navigateAllTabsHome(tabs) { const homeUrl = policy.homeUrl || "about:blank"; const targetTabs = tabs.filter((tab) => !tab.url || !tab.url.includes("admin.html")); targetTabs.forEach((tab) => browser.tabs.update(tab.id, { url: homeUrl }).catch(() => {})); console.log(`[vestibule] ${targetTabs.length} tab(s) navigated to ${homeUrl}`); return tabs; } function maybeShowLockOverlay(reason) { const onReset = reason.startsWith("wake:") ? policy.onWake : policy.onReset; if (onReset !== "lock" && onReset !== "both") return; broadcastToActiveTab({ type: "show-lock-overlay" }); } // ─── Admin grace mode ───────────────────────────────────────────────── function isInAdminGrace() { return Date.now() < adminGraceUntil; } function enterAdminGrace() { adminGraceUntil = Date.now() + ADMIN_GRACE_MS; console.log(`[vestibule] admin grace entered for ${ADMIN_GRACE_MS / 1000}s`); } // ─── Idle timeout polling ───────────────────────────────────────────── setInterval(() => { const threshold = policy.idleTimeoutS || 300; browser.idle.queryState(threshold).then( (state) => { if (state === "idle" || state === "locked") resetSession("idle"); }, (err) => console.warn("[vestibule] idle query failed:", err) ); }, IDLE_POLL_INTERVAL_MS); // ─── Native Messaging bridge ────────────────────────────────────────── function connectNative() { if (nativePort) return; try { nativePort = browser.runtime.connectNative(NATIVE_HOST); nativePort.onMessage.addListener(handleNativeMessage); nativePort.onDisconnect.addListener(() => { const err = browser.runtime.lastError; console.warn("[vestibule] usher disconnected:", err && err.message); nativePort = null; setTimeout(connectNative, 5000); }); nativePort.postMessage({ type: "hello", client: "vestibule", version: browser.runtime.getManifest().version, }); } catch (e) { console.error("[vestibule] native connect failed:", e); } } const NATIVE_HANDLERS = { hello: (msg) => console.log("[vestibule] usher hello:", msg.server, msg.version), pong: (msg) => console.log("[vestibule] usher pong, echo:", msg.echo), wake: (msg) => resetSession("wake:" + (msg.reason || "unknown")), "unlock-result": handleUnlockResult, "unlock-set": (msg) => { // Forward to admin wizard (which is listening via runtime.onMessage) browser.runtime.sendMessage({ type: "unlock-set-result", ok: msg.ok, error: msg.error, }).catch(() => {}); }, }; function handleNativeMessage(msg) { console.log("[vestibule] from usher:", msg); const handler = NATIVE_HANDLERS[msg.type]; if (handler) handler(msg); else console.warn("[vestibule] unknown native message:", msg); } function handleUnlockResult(msg) { // Forward to the unlock popup browser.runtime.sendMessage({ type: "unlock-result", granted: msg.granted, reason: msg.reason, }).catch(() => {}); if (!msg.granted) return; // Granted: close popup, clear lock overlay, enter admin grace if (unlockPopupId !== null) { browser.windows.remove(unlockPopupId).catch(() => {}); unlockPopupId = null; } broadcastToActiveTab({ type: "hide-lock-overlay" }); enterAdminGrace(); console.log("[vestibule] unlock granted, admin grace active"); } function sendToNative(msg) { if (!nativePort) connectNative(); if (!nativePort) return; try { nativePort.postMessage(msg); } catch (e) { console.error("[vestibule] send to native failed:", e); nativePort = null; setTimeout(connectNative, 1000); } } // ─── Unlock popup management ────────────────────────────────────────── function openUnlockPopup() { if (unlockPopupId !== null) { browser.windows.update(unlockPopupId, { focused: true }).catch(() => {}); return; } browser.windows .create({ url: browser.runtime.getURL("unlock.html"), type: "popup", width: 360, height: 280, left: Math.round((screen.availWidth - 360) / 2), top: Math.round((screen.availHeight - 280) / 2), }) .then((win) => { unlockPopupId = win.id; browser.windows.onRemoved.addListener((windowId) => { if (windowId === unlockPopupId) unlockPopupId = null; }); }) .catch((err) => console.error("[vestibule] popup create failed:", err)); } // ─── Admin wizard command ───────────────────────────────────────────── browser.commands.onCommand.addListener((command) => { if (command !== "open-admin-wizard") return; console.log("[vestibule] opening admin wizard"); browser.tabs.create({ url: browser.runtime.getURL("admin.html") }); }); // ─── Broadcasting helpers ───────────────────────────────────────────── function broadcastToActiveTab(message) { browser.tabs.query({ active: true, currentWindow: true }).then( (tabs) => tabs[0] && browser.tabs.sendMessage(tabs[0].id, message).catch(() => {}), () => {} ); } // ─── Message routing (table-driven dispatch) ────────────────────────── const MESSAGE_HANDLERS = { "ping-usher": (msg) => { sendToNative({ type: "ping", echo: msg.echo || "vestibule" }); return { ok: true }; }, "unlock-attempt": (msg) => { sendToNative({ type: "unlock", password: msg.password || "" }); return { ok: true, queued: true }; }, "set-unlock-password": (msg) => { sendToNative({ type: "set-unlock", password: msg.password || "" }); return { ok: true, queued: true }; }, "open-unlock-popup": () => { openUnlockPopup(); return { ok: true }; }, "get-policy": () => policy, "set-policy": (msg) => { policy = { ...policy, ...msg.policy }; browser.storage.local.set({ policy }); return { ok: true }; }, "navigate-home": (msg, sender) => { const tabId = sender.tab ? sender.tab.id : null; const target = { url: policy.homeUrl || "about:blank" }; if (tabId !== null) browser.tabs.update(tabId, target); else browser.tabs.create(target); return { ok: true }; }, "manual-reset": () => { resetSession("manual"); return { ok: true }; }, "open-admin": () => { browser.tabs.create({ url: browser.runtime.getURL("admin.html") }); return { ok: true }; }, }; browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { const handler = MESSAGE_HANDLERS[msg.type]; if (!handler) { console.warn("[vestibule] unknown runtime message:", msg); return false; } sendResponse(handler(msg, sender)); return false; }); // ─── Boot ───────────────────────────────────────────────────────────── connectNative(); console.log( "[vestibule] background loaded, version", browser.runtime.getManifest().version ); console.log("[vestibule] admin wizard: Ctrl+Shift+V or gear icon");