176 lines
10 KiB
JavaScript
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);
|