Vestibule/extension/url-policy.js

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,
};
});