415 lines
14 KiB
JavaScript
Executable File
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");
|