157 lines
6.7 KiB
JavaScript
157 lines
6.7 KiB
JavaScript
// 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,
|
|
};
|
|
});
|