theseus/dev/blocklist-selftest.js
Local Dev 828100e2ce Theseus: warn before opening a name a blocklist flags
The blocklist consumer existed in the resolver library and the gateway, but
the browser opened a flagged name without comment. Now the indexer process
reads the subscribed lists from the chain every ten minutes and hands the
flags to main. A flagged name loads a warning page naming the reason, the
list and the report; the user may continue, and that is remembered per name.

Two gates, because content is reached two ways. loadBns shows the real
interstitial. serveBns refuses with an inline page on every path that skips
it: reload, back and forward, bns:// links, web app windows. The inline page
has no button, since a page at the site's own origin must not be able to
approve itself; only blocked.html may ask to continue, checked by file URL.

Settings › Naming has the policy: warn (default), never open, or ignore the
lists. The csam reason is never offered a way through. A list that cannot be
read keeps the last known flags and never stops a name from resolving.

The gateway put its own warning in front of flagged sites, which this
browser could not get past: it fetches files itself, with no cookie jar. It
now sends x-bns-policy: client and the gateway stays out of the way for a
client that says it decides for itself.

dev/blocklist-selftest.js runs the real protocol handler and decision
functions against a scratch profile.
2026-10-04 15:50:35 +02:00

99 lines
6.3 KiB
JavaScript

// Blocklist harness — exercises the blocklist policy through the REAL
// serveBns handler and the real decision functions in main.js, without
// starting the app (so no updater, no Ariadne, no real profile).
//
// set THESEUS_USER_DATA=<scratch dir>
// npx electron dev/blocklist-selftest.js [name] (default: hello.bch)
//
// The flags normally arrive from the indexer process, which reads the lists
// from the chain; here the harness delivers them through the same message
// handler. It checks:
// 1. an unflagged name is served as before
// 2. a flagged name gets the inline warning from the protocol handler (403)
// 3. "continue anyway" is remembered per name, and only for a flagged name
// 4. policy "refuse" ignores that choice; reason "csam" never offers one;
// policy "off" ignores the lists
// 5. blocked.html renders the flag and shows/hides "continue" as told
// Prints a JSON report and exits 0 only if every check passed.
process.env.THESEUS_NO_AUTOSTART = "1";
if (!process.env.THESEUS_USER_DATA) { console.log("refusing to run without THESEUS_USER_DATA (it would use the real profile)"); process.exit(2); }
const { app, BrowserWindow, protocol } = require("electron");
const { serveBns, _blocklistTest: B } = require("../main.js");
const fs = require("fs");
const path = require("path");
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("no-sandbox");
const name = (process.argv[2] || "hello.bch").toLowerCase();
const outDir = path.join(__dirname, "..", "dev-out");
const watchdog = setTimeout(() => { console.log("WATCHDOG: timed out"); try { app.exit(3); } catch {} }, 90000);
const checks = [];
const check = (what, ok, detail) => { checks.push({ what, ok: !!ok, ...(detail !== undefined ? { detail } : {}) }); };
const FLAG = (reason) => ({ name, reason, reportTxid: "ab".repeat(32), txid: "cd".repeat(32), height: 1, source: "test-list.x" });
app.whenReady().then(async () => {
protocol.handle("bns", serveBns);
const win = new BrowserWindow({ width: 1000, height: 700, show: false, webPreferences: { offscreen: true } });
const wc = win.webContents;
const titleOf = async (url) => { try { await wc.loadURL(url); } catch {} await new Promise((r) => setTimeout(r, 1500)); return wc.getTitle(); };
const statusOf = async (url) => (await serveBns(new Request(url))).status;
const WARN = `Warning: ${name} is flagged`;
// 1. unflagged
const plainTitle = await titleOf(`bns://${name}/`);
check("unflagged name is served as before", plainTitle !== WARN && (await statusOf(`bns://${name}/`)) !== 403, plainTitle);
check("no decision without a flag", B.decision({ name }, name) === null);
// 2. flagged
B.setFlags([FLAG("malware")]);
const d = B.decision({ name }, name);
check("flagged name: decision offers continue under the default policy", d && d.canContinue === true && d.flag.reason === "malware");
check("a subdomain is governed by its parent's flag", !!B.decision({ name }, "www." + name));
check("protocol handler answers 403 for a flagged name", (await statusOf(`bns://${name}/`)) === 403);
check("…and for its subresources", (await statusOf(`bns://${name}/style.css`)) === 403);
check("the inline warning is what renders", (await titleOf(`bns://${name}/`)) === WARN);
const inline = await wc.executeJavaScript("document.body.innerHTML");
check("the inline warning has no way to approve itself", !/block-choose|<button|<a /i.test(inline));
// 3. continue anyway
check("continuing is refused for a name that is not flagged", B.remember("not-flagged.x") === false);
check("continuing is remembered for the flagged name", B.remember(name) === true);
check("after that the name opens", B.decision({ name }, name) === null && (await statusOf(`bns://${name}/`)) !== 403);
const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "blocklist.json"), "utf8"));
check("the choice is persisted per name", !!saved.byName[name] && saved.byName[name].reason === "malware", Object.keys(saved.byName));
// 4. policies
B.setPolicy("refuse");
const r = B.decision({ name }, name);
check('policy "refuse" ignores an earlier "continue"', r && r.canContinue === false);
check('…and "continue" cannot be recorded under it', B.remember(name) === false);
B.setPolicy("off");
check('policy "off" ignores the lists', B.decision({ name }, name) === null);
B.setPolicy("warn"); B.forget();
B.setFlags([FLAG("csam")]);
const c = B.decision({ name }, name);
check("reason csam is never offered a way through", c && c.canContinue === false && B.remember(name) === false);
B.setFlags([]);
check("an emptied list lifts the flag", B.decision({ name }, name) === null);
// 5. the interstitial page
const page = (proceed) => ({ search: new URLSearchParams({ host: name, name, reason: "phishing", list: "test-list.x", report: "ab".repeat(32), resturl: "/", proceed }).toString() });
await wc.loadFile(path.join(__dirname, "..", "blocked.html"), page("1"));
await new Promise((r2) => setTimeout(r2, 800));
const withGo = await wc.executeJavaScript(`({ title: document.title, name: document.getElementById("name").textContent, why: document.getElementById("why").textContent, go: !document.getElementById("go").hidden, refused: !document.getElementById("refused").hidden, list: document.getElementById("list").textContent })`);
check("blocked.html shows the flag", withGo.title === WARN && withGo.name === name && /imitates another site/.test(withGo.why) && withGo.list === "test-list.x", withGo);
check("blocked.html offers continue when told it may", withGo.go === true && withGo.refused === false);
fs.mkdirSync(outDir, { recursive: true });
try { fs.writeFileSync(path.join(outDir, "blocked.png"), (await wc.capturePage()).toPNG()); } catch {}
await wc.loadFile(path.join(__dirname, "..", "blocked.html"), page("0"));
await new Promise((r2) => setTimeout(r2, 800));
const noGo = await wc.executeJavaScript(`({ go: !document.getElementById("go").hidden, refused: !document.getElementById("refused").hidden })`);
check("blocked.html hides continue when refused", noGo.go === false && noGo.refused === true);
const failed = checks.filter((x) => !x.ok);
console.log(JSON.stringify({ name, passed: checks.length - failed.length, failed: failed.length, checks }, null, 1));
clearTimeout(watchdog);
app.exit(failed.length ? 1 : 0);
});