Vestibule/extension/background.js

415 lines
14 KiB
JavaScript
Executable File

// 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: ["<all_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");