// Vestibule URL policy engine — pure logic, no browser API calls. // // This module is the single source of truth for navigation decisions. // It is loaded as a classic background script before background.js // (which owns the webRequest wiring and policy state) and imported by // scripts/test-url-policy.js under Node. The UMD-lite export keeps // both worlds working from one file. // // Modes (one row per mode in MODE_TABLE): // safelist — default-deny by domain. Every request whose hostname is // not on the operator's safelist is blocked. Internal // browser schemes and the configured home origin are // always permitted. This is the default mode: a fresh // install can load its home page and nothing else until // the operator adds domains. // open — no filtering. // blocklist — substring block (legacy substring semantics). // allowlist — substring allow (legacy substring semantics; an empty // list allows everything — use safelist instead). // // Domain matching is hostname-based, never substring-based: the entry // "example.org" permits example.org and any subdomain // (portal.example.org), and nothing else. A URL whose query string // merely contains "example.org" does not match — the failure mode of // substring matching and the reason safelist mode exists. (function (root, factory) { const api = factory(); if (typeof module !== "undefined" && module.exports) { module.exports = api; // Node (unit tests) } else { root.VestibuleUrlPolicy = api; // extension background / wizard page } })(typeof self !== "undefined" ? self : globalThis, function () { // Schemes the browser itself needs. Blocking these breaks the admin // wizard, the unlock popup, and about:blank — the kiosk would brick. const INTERNAL_SCHEMES = ["about:", "moz-extension:", "chrome:", "resource:"]; // Same-document artifacts. Safe as subresources; blocked as // top-level documents (a data: URL navigation is a known content- // injection vector and has no legitimate kiosk use). const DATA_LIKE_SCHEMES = ["data:", "blob:"]; const schemeOf = (url) => { const idx = url.indexOf(":"); return idx === -1 ? "" : url.slice(0, idx + 1).toLowerCase(); }; const hostnameOf = (url) => { try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ""; } }; // Normalize one operator-supplied safelist entry to a bare hostname. // Accepts domains, wildcard-prefixed domains, and pasted URLs; // returns null when nothing hostname-shaped can be extracted. const normalizeDomainEntry = (entry) => { const raw = String(entry || "").trim().toLowerCase(); if (!raw) return null; const stripped = raw.startsWith("*.") ? raw.slice(2) : raw; const candidate = /^[a-z][a-z0-9+.-]*:/.test(stripped) || stripped.includes("/") ? stripped // URL-ish — let the URL parser take it apart : "http://" + stripped + "/"; // bare domain — synthesize a URL const host = hostnameOf(candidate); return host || null; }; // Entry "example.org" matches hostname example.org and any // subdomain of it. The leading-dot boundary is explicit: a sibling // like evilexample.org must not match. const hostMatchesEntry = (hostname, entry) => hostname === entry || hostname.endsWith("." + entry); const domainAllowed = (url, safelist) => { const hostname = hostnameOf(url); if (!hostname) return false; // file:, malformed — nothing to match const entries = (safelist || []) .map(normalizeDomainEntry) .filter((e) => e !== null); return entries.some((entry) => hostMatchesEntry(hostname, entry)); }; // The home origin is always navigable regardless of safelist // contents: session reset, wake, and idle all navigate home, and a // block there would leave the kiosk showing its own block page // forever. Exact hostname match — subdomains of home are not // covered; the operator lists them explicitly. const homeHostnameOf = (homeUrl) => { const host = hostnameOf(homeUrl || ""); if (!host) return null; const scheme = schemeOf(homeUrl); return scheme === "http:" || scheme === "https:" ? host : null; }; const substringMatches = (url, patterns) => (patterns || []).some((pat) => url.toLowerCase().includes(String(pat).toLowerCase()) ); const SAFELIST_PREDICATE = (url, policy, ctx) => { const scheme = schemeOf(url); if (INTERNAL_SCHEMES.includes(scheme)) return false; // always allow if (DATA_LIKE_SCHEMES.includes(scheme)) return ctx.mainFrame; // subresource only if (domainAllowed(url, policy.safelist)) return false; return hostnameOf(url) !== ctx.homeHostname; // home origin passes }; // One row per mode. Unknown modes resolve to the safelist row: // a corrupted or hand-edited policy string fails closed — the kiosk // locks to its home page instead of opening the perimeter. const MODE_TABLE = { safelist: SAFELIST_PREDICATE, open: () => false, blocklist: (url, policy) => substringMatches(url, policy.blocklist), allowlist: (url, policy) => (policy.allowlist || []).length > 0 && !substringMatches(url, policy.allowlist), }; const shouldBlockRequest = (url, policy, ctx) => { const context = { mainFrame: !!(ctx && ctx.mainFrame), homeHostname: (ctx && ctx.homeHostname) || null, }; const predicate = MODE_TABLE[policy.mode] || MODE_TABLE.safelist; return predicate(url, policy, context); }; // First-boot adoption: a provisioned kiosk launches with its home URL // on the command line (kiosk.env → --kiosk URL), which the extension // cannot learn any other way. When the policy is still the default // (about:blank home, empty safelist), the browser's startup page is // adopted as the home URL and its domain becomes the first safelist // entry. Runs once against the tab list at background startup — only // pages the browser was launched with qualify, never later // operator-typed navigations. Returns the new policy, or null when // nothing should change. const adoptStartupPolicy = (startupUrls, policy) => { if (!policy || policy.homeUrl !== "about:blank") return null; if ((policy.safelist || []).length > 0) return null; const url = (startupUrls || []).find((u) => homeHostnameOf(u) !== null); if (!url) return null; return { ...policy, homeUrl: url, safelist: [homeHostnameOf(url)] }; }; return { INTERNAL_SCHEMES: INTERNAL_SCHEMES.slice(), normalizeDomainEntry: normalizeDomainEntry, hostnameOf: hostnameOf, homeHostnameOf: homeHostnameOf, shouldBlockRequest: shouldBlockRequest, adoptStartupPolicy: adoptStartupPolicy, }; });