Vestibule/scripts/test-url-policy.js

176 lines
10 KiB
JavaScript

#!/usr/bin/env node
// test-url-policy.js — unit tests for the Vestibule URL policy engine.
//
// The engine (extension/url-policy.js) is pure logic with no browser
// dependencies, so Node loads it directly through the module.exports
// arm of its UMD-lite export. Run: node scripts/test-url-policy.js
//
// Structure follows the project's table-driven test convention:
// every case is a row; the driver is one loop; the failure report
// names the case, the input, and both expectation and result.
"use strict";
const P = require("../extension/url-policy.js");
let passed = 0;
let failed = 0;
function check(label, actual, expected) {
const ok = actual === expected;
if (ok) {
passed += 1;
} else {
failed += 1;
console.error(` [FAIL] ${label}`);
console.error(` expected: ${JSON.stringify(expected)}`);
console.error(` actual: ${JSON.stringify(actual)}`);
}
}
// ─── Entry normalization ────────────────────────────────────────────
console.log("==> normalizeDomainEntry");
const NORMALIZE_CASES = [
// [input, expected hostname]
["example.org", "example.org"],
[" Example.ORG ", "example.org"], // surrounding whitespace + case
["*.example.org", "example.org"], // wildcard prefix stripped
["https://portal.example.org/welcome", "portal.example.org"], // pasted URL
["http://example.org:8080/path?q=1", "example.org"], // port and path dropped
["https://Example.Org/", "example.org"],
["not a domain", null], // spaces inside — no hostname
["", null],
[null, null],
[" ", null],
];
NORMALIZE_CASES.forEach(([input, expected]) => {
check(`normalize(${JSON.stringify(input)})`, P.normalizeDomainEntry(input), expected);
});
// ─── Home hostname extraction ───────────────────────────────────────
console.log("==> homeHostnameOf");
check("http home", P.homeHostnameOf("http://kiosk.example.org/start"), "kiosk.example.org");
check("https home", P.homeHostnameOf("https://portal.example.org/"), "portal.example.org");
check("about:blank is not a home origin", P.homeHostnameOf("about:blank"), null);
check("empty", P.homeHostnameOf(""), null);
check("missing", P.homeHostnameOf(undefined), null);
// ─── Safelist mode decisions ────────────────────────────────────────
console.log("==> safelist mode");
const SAFE = { mode: "safelist", safelist: ["example.org", "cdn.example.net"] };
const sub = { mainFrame: true }; // subresource context would be {mainFrame:false}
check("listed domain allowed", P.shouldBlockRequest("https://example.org/welcome", SAFE, sub), false);
check("subdomain of listed domain allowed", P.shouldBlockRequest("https://portal.example.org/", SAFE, sub), false);
check("deep subdomain allowed", P.shouldBlockRequest("https://a.b.example.org/x", SAFE, sub), false);
check("second entry allowed", P.shouldBlockRequest("https://cdn.example.net/lib.js", SAFE, sub), false);
check("unlisted domain blocked", P.shouldBlockRequest("https://evil.com/", SAFE, sub), true);
check("sibling domain blocked", P.shouldBlockRequest("https://evilexample.org/", SAFE, sub), true);
check("query-string smuggle blocked", P.shouldBlockRequest("https://evil.com/?q=example.org", SAFE, sub), true);
check("path smuggle blocked", P.shouldBlockRequest("https://evil.com/example.org", SAFE, sub), true);
check("userinfo smuggle blocked", P.shouldBlockRequest("https://example.org@evil.com/", SAFE, sub), true);
check("empty hostname (file:) blocked", P.shouldBlockRequest("file:///etc/passwd", SAFE, sub), true);
// Internal schemes — the browser machinery must keep working.
check("about:blank always allowed", P.shouldBlockRequest("about:blank", SAFE, sub), false);
check("moz-extension always allowed", P.shouldBlockRequest("moz-extension://abc/blocked.html?u=x", SAFE, sub), false);
check("chrome: always allowed", P.shouldBlockRequest("chrome://global/skin/", SAFE, sub), false);
check("resource: always allowed", P.shouldBlockRequest("resource://gre/modules/", SAFE, sub), false);
// data:/blob: — subresource yes, top-level no.
check("data: subresource allowed", P.shouldBlockRequest("data:image/png;base64,AAA", SAFE, { mainFrame: false }), false);
check("blob: subresource allowed", P.shouldBlockRequest("blob:https://example.org/uuid", SAFE, { mainFrame: false }), false);
check("data: top-level blocked", P.shouldBlockRequest("data:text/html,<script>1</script>", SAFE, { mainFrame: true }), true);
check("blob: top-level blocked", P.shouldBlockRequest("blob:https://example.org/uuid", SAFE, { mainFrame: true }), true);
// Empty safelist — the safe-by-default posture: nothing external loads.
const EMPTY = { mode: "safelist", safelist: [] };
check("empty safelist blocks http", P.shouldBlockRequest("https://example.org/", EMPTY, sub), true);
check("empty safelist allows about:blank", P.shouldBlockRequest("about:blank", EMPTY, sub), false);
// Unnormalized entries in the list still match (storage written by
// hand, older tools, etc. — the engine normalizes on read).
const RAW = { mode: "safelist", safelist: ["https://Example.ORG/path"] };
check("raw URL entry normalizes on read", P.shouldBlockRequest("https://sub.example.org/", RAW, sub), false);
// ─── Home-origin guarantee ──────────────────────────────────────────
console.log("==> home-origin guarantee");
const HOME_CTX = { mainFrame: true, homeHostname: "lobby.example.org" };
check("home origin passes empty safelist", P.shouldBlockRequest("https://lobby.example.org/start", EMPTY, HOME_CTX), false);
check("home origin passes with safelist active", P.shouldBlockRequest("https://lobby.example.org/", SAFE, HOME_CTX), false);
check("subdomain of home is NOT auto-covered", P.shouldBlockRequest("https://www.lobby.example.org/", EMPTY, HOME_CTX), true);
check("sibling of home blocked", P.shouldBlockRequest("https://lobby.example.org.evil.com/", EMPTY, HOME_CTX), true);
check("home exemption applies to subresources too", P.shouldBlockRequest("https://lobby.example.org/app.js", EMPTY, { mainFrame: false, homeHostname: "lobby.example.org" }), false);
// ─── Legacy modes (behavior unchanged from 1.2.0) ──────────────────
console.log("==> legacy modes");
check("open never blocks", P.shouldBlockRequest("https://anything.anywhere/", { mode: "open" }, sub), false);
check("blocklist blocks substring", P.shouldBlockRequest("https://example.org/blocked/x", { mode: "blocklist", blocklist: ["example.org/blocked"] }, sub), true);
check("blocklist allows the rest", P.shouldBlockRequest("https://example.org/ok", { mode: "blocklist", blocklist: ["example.org/blocked"] }, sub), false);
check("allowlist allows listed substring", P.shouldBlockRequest("https://example.org/x", { mode: "allowlist", allowlist: ["example.org"] }, sub), false);
check("allowlist blocks unlisted", P.shouldBlockRequest("https://other.org/", { mode: "allowlist", allowlist: ["example.org"] }, sub), true);
check("allowlist with empty list allows everything (documented legacy quirk)", P.shouldBlockRequest("https://anything.org/", { mode: "allowlist", allowlist: [] }, sub), false);
check("substring allowlist passes query-string smuggle (the weakness safelist fixes)", P.shouldBlockRequest("https://evil.com/?q=example.org", { mode: "allowlist", allowlist: ["example.org"] }, sub), false);
// ─── Fail-closed on unknown mode ────────────────────────────────────
console.log("==> unknown mode fails closed");
const GARBAGE = { mode: "alllowlist", safelist: [] }; // corrupted policy
check("unknown mode blocks external", P.shouldBlockRequest("https://evil.com/", GARBAGE, sub), true);
check("unknown mode keeps internal pages working", P.shouldBlockRequest("about:blank", GARBAGE, sub), false);
check("unknown mode keeps home working", P.shouldBlockRequest("https://home.org/", GARBAGE, { mainFrame: true, homeHostname: "home.org" }), false);
// ─── First-boot startup adoption ────────────────────────────────────
console.log("==> startup adoption");
const DEFAULTS = { mode: "safelist", safelist: [], homeUrl: "about:blank" };
let adopted = P.adoptStartupPolicy(
["about:blank", "https://checkin.example.org/welcome"], DEFAULTS);
check("provisioned startup page adopted", adopted !== null, true);
check("adopted home URL is the startup page",
!!(adopted && adopted.homeUrl === "https://checkin.example.org/welcome"), true);
check("adopted safelist is the page's domain",
!!(adopted && adopted.safelist.length === 1 && adopted.safelist[0] === "checkin.example.org"), true);
check("no http startup tabs → no adoption",
P.adoptStartupPolicy(["about:blank"], DEFAULTS), null);
check("no tabs at all → no adoption", P.adoptStartupPolicy([], DEFAULTS), null);
check("configured home → no adoption",
P.adoptStartupPolicy(["https://checkin.example.org/"], { ...DEFAULTS, homeUrl: "https://other.example.org/" }), null);
check("non-empty safelist → no adoption",
P.adoptStartupPolicy(["https://checkin.example.org/"], { ...DEFAULTS, safelist: ["existing.example.org"] }), null);
check("null policy → no adoption", P.adoptStartupPolicy(["https://x.example.org/"], null), null);
// The adopted policy must actually admit the startup page through the
// engine (home guarantee + safelist entry).
check("adopted policy admits the startup page",
!!(adopted && !P.shouldBlockRequest(adopted.homeUrl, adopted, { mainFrame: true })), true);
check("adopted policy still blocks other domains",
!!(adopted && P.shouldBlockRequest("https://evil.com/", adopted, { mainFrame: true })), true);
check("adopted policy admits subdomains of the home domain",
!!(adopted && !P.shouldBlockRequest("https://portal.checkin.example.org/", adopted, { mainFrame: true })), true);
// ─── Report ─────────────────────────────────────────────────────────
console.log("");
if (failed === 0) {
console.log(`OK — ${passed}/${passed + failed} URL policy assertions pass.`);
process.exit(0);
}
console.log(`FAIL: ${failed} of ${passed + failed} assertions failed.`);
process.exit(1);