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.
This commit is contained in:
parent
99d7684590
commit
828100e2ce
7 changed files with 410 additions and 3 deletions
103
blocked.html
Normal file
103
blocked.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warning</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--
|
||||
Shown in a tab in place of a name that a subscribed blocklist flags
|
||||
(Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md). Loaded from disk by
|
||||
loadBns() in main.js with the flag in the query string.
|
||||
|
||||
"Continue" navigates to bns://block-choose/, which the tab's will-navigate
|
||||
listener intercepts — and honours only when the page asking is this file.
|
||||
Nothing here is trusted beyond that: main.js re-checks the flag itself.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #140b0d; --fg: #f3e9ea; --muted: #b98b92; --border: #5a2630; --panel: #1c0f12; --warn: #ff8f9c;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #fbf3f4; --fg: #2a1216; --muted: #7a4a52; --border: #e6b9c0; --panel: #ffffff; --warn: #b3261e; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center; padding: 40px 24px;
|
||||
}
|
||||
main { width: 100%; max-width: 640px; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 28px 30px; }
|
||||
h1 { margin: 0 0 10px; font-size: 21px; font-weight: 600; color: var(--warn); letter-spacing: -.01em; }
|
||||
p { margin: 10px 0; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
code { font: 12px ui-monospace, "Cascadia Mono", Consolas, monospace; word-break: break-all; }
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 16px 0; font-size: 13px; }
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; }
|
||||
.actions { display: flex; align-items: center; gap: 18px; margin-top: 22px; flex-wrap: wrap; }
|
||||
button { font: inherit; cursor: pointer; }
|
||||
#back { padding: 10px 20px; border-radius: 999px; border: 0; background: var(--fg); color: var(--bg); font-weight: 600; }
|
||||
#go { background: none; border: 0; padding: 0; color: var(--muted); text-decoration: underline; font-size: 13px; }
|
||||
#go[hidden] { display: none; }
|
||||
kbd { font: 11px ui-monospace, monospace; opacity: .6; margin-left: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>⚠ <span id="name"></span> is flagged as dangerous</h1>
|
||||
<p id="why"></p>
|
||||
<dl>
|
||||
<dt>Reason</dt><dd><code id="reason"></code></dd>
|
||||
<dt>List</dt><dd><code id="list"></code></dd>
|
||||
<dt>Report</dt><dd><code id="report"></code></dd>
|
||||
</dl>
|
||||
<p class="muted">The name is still registered on chain and nobody has taken it from its owner. This warning comes from a blocklist Theseus subscribes to; the flag and the report behind it are public transactions anyone can read. You can change what Theseus does with flagged names in Settings › Naming.</p>
|
||||
<p class="muted" id="refused" hidden>Theseus does not open names reported for this reason.</p>
|
||||
<div class="actions">
|
||||
<button id="back">Go back<kbd>Esc</kbd></button>
|
||||
<button id="go" hidden>Continue to the site anyway</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const host = qs.get("host") || "";
|
||||
const name = qs.get("name") || host;
|
||||
const reason = qs.get("reason") || "";
|
||||
const resturl = qs.get("resturl") || "/";
|
||||
const canProceed = qs.get("proceed") === "1";
|
||||
const WHY = {
|
||||
"phishing": "It imitates another site to steal passwords, recovery phrases or funds.",
|
||||
"impersonation": "It passes itself off as a person or organisation that has not authorised it.",
|
||||
"malware": "It serves software designed to compromise your device.",
|
||||
"scam-financial": "It makes a fraudulent financial offer, such as a fake exchange or a wallet drainer.",
|
||||
"csam": "It was reported for child sexual abuse material.",
|
||||
"stolen": "Its certificate was reported as moved without the owner's consent.",
|
||||
};
|
||||
document.getElementById("name").textContent = name;
|
||||
document.getElementById("why").textContent = WHY[reason] || "It was reported as dangerous.";
|
||||
document.getElementById("reason").textContent = reason;
|
||||
document.getElementById("list").textContent = qs.get("list") || "";
|
||||
document.getElementById("report").textContent = qs.get("report") || "";
|
||||
document.title = "Warning: " + name + " is flagged";
|
||||
|
||||
const go = document.getElementById("go");
|
||||
go.hidden = !canProceed;
|
||||
document.getElementById("refused").hidden = canProceed;
|
||||
|
||||
function back() {
|
||||
if (history.length > 1) history.back();
|
||||
else location.href = "about:blank";
|
||||
}
|
||||
document.getElementById("back").addEventListener("click", back);
|
||||
go.addEventListener("click", () => {
|
||||
// Intercepted by main.js (will-navigate); only this page may ask.
|
||||
location.href = "bns://block-choose/?" + new URLSearchParams({ host, name, choice: "continue", resturl }).toString();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") back(); });
|
||||
document.getElementById("back").focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -237,6 +237,32 @@ function stopOwn() {
|
|||
publish("handback");
|
||||
}
|
||||
|
||||
// ---- blocklists -------------------------------------------------------------
|
||||
// The lists the network subscribes to by default, read from the chain
|
||||
// (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md) and sent to main as
|
||||
// { type: "blocklist" }. Read here because this process is the one with a
|
||||
// resolver and a (Tor-aware) electrum socket. The lists are advisory: a failed
|
||||
// read is reported and main keeps the flags it had.
|
||||
const BLOCKLIST_FIRST_MS = 12_000;
|
||||
const BLOCKLIST_EVERY_MS = 10 * 60_000;
|
||||
let blocklistBusy = false;
|
||||
async function readBlocklists() {
|
||||
if (blocklistBusy || !R || typeof R.buildBlocklists !== "function") return;
|
||||
blocklistBusy = true;
|
||||
try {
|
||||
const lists = (R.NETWORK && R.NETWORK.blocklists) || [];
|
||||
const idx = active() === "own" ? own.index : chain && chain.index;
|
||||
const flags = lists.length ? await R.buildBlocklists({ lists, index: idx || null, WebSocket: currentWS(), directIP: true }) : new Map();
|
||||
port.postMessage({
|
||||
type: "blocklist", ok: true, at: Date.now(),
|
||||
lists: lists.map((l) => ({ name: l.name || null, address: R.blocklistAddress(l, idx || null) })),
|
||||
flags: [...flags.values()].map((f) => ({ name: f.name, reason: f.reason, reportTxid: f.reportTxid, txid: f.txid, height: f.height, evidence: f.evidence || null, source: f.source })),
|
||||
});
|
||||
} catch (e) {
|
||||
port.postMessage({ type: "blocklist", ok: false, error: e?.message || String(e) });
|
||||
} finally { blocklistBusy = false; }
|
||||
}
|
||||
|
||||
// ---- boot -------------------------------------------------------------------
|
||||
async function init(m) {
|
||||
cfg = m;
|
||||
|
|
@ -285,6 +311,8 @@ async function init(m) {
|
|||
if (!checked.length) startOwn("no Ariadne indexer installed");
|
||||
setTimeout(checkHealth, LAUNCH_GRACE_MS);
|
||||
setInterval(checkHealth, CHECK_EVERY_MS);
|
||||
setTimeout(readBlocklists, BLOCKLIST_FIRST_MS);
|
||||
setInterval(readBlocklists, BLOCKLIST_EVERY_MS);
|
||||
}
|
||||
let initP = null;
|
||||
|
||||
|
|
@ -320,6 +348,7 @@ port.on("message", async (e) => {
|
|||
// when it is live, else the own one; allowed before "go".
|
||||
case "poll": reply(await poll()); break;
|
||||
case "rebuild": await initP; reply(await startOwn(ownReason || "rebuild requested").rebuild()); break;
|
||||
case "blocklist": await initP; await readBlocklists(); reply(true); break;
|
||||
default: reply(false, "unknown request");
|
||||
}
|
||||
} catch (err) { reply(false, err?.message || String(err)); }
|
||||
|
|
|
|||
99
dev/blocklist-selftest.js
Normal file
99
dev/blocklist-selftest.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// 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);
|
||||
});
|
||||
146
main.js
146
main.js
|
|
@ -506,6 +506,11 @@ const SETTINGS_DEFAULTS = {
|
|||
// "icann-first" — ICANN wins collisions; BCNR fills gaps.
|
||||
// "soft" — "Open with…" prompt on collision, remembered per name/TLD.
|
||||
collisionPolicy: "bcnr-first",
|
||||
// Names flagged by a subscribed blocklist (PROTOCOL-ADDENDUM-blocklist.md):
|
||||
// "warn" — a warning page first; the user may continue, per name (default)
|
||||
// "refuse" — never opened
|
||||
// "off" — the lists are ignored
|
||||
blocklistPolicy: "warn",
|
||||
// Add-on framework: ids the user has explicitly turned off. Installed but
|
||||
// disabled add-ons are still discovered — they just never activate.
|
||||
disabledAddons: [],
|
||||
|
|
@ -878,6 +883,7 @@ const SETTINGS_ONLY = new Set([
|
|||
"addons-list", "addons-open-dir", "addons-reload", "addons-remove", "addons-reveal", "addons-set-enabled",
|
||||
"ariadne-get-policy", "ariadne-get-status", "ariadne-install", "ariadne-set-policy", "ariadne-set-source",
|
||||
"ariadne-state", "ariadne-toggle", "ariadne-uninstall", "ariadne-update",
|
||||
"blocklist-reset", "blocklist-set-policy",
|
||||
"clear-browsing-data", "collision-reset", "collision-set-policy",
|
||||
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
|
||||
"password-setup", "password-status", "password-unlock", "password-update",
|
||||
|
|
@ -893,7 +899,7 @@ const SETTINGS_ONLY = new Set([
|
|||
// only its own window may drive it.
|
||||
const HERMES_ONLY = new Set(["hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", "hermes-inbox", "hermes-send"]);
|
||||
const SETTINGS_SHARED = new Set([
|
||||
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state",
|
||||
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "blocklist-state", "collision-state",
|
||||
"remove-engine", "settings-get", "settings-set", "toggle-tor",
|
||||
]);
|
||||
function isSettingsPage(sender) {
|
||||
|
|
@ -1023,6 +1029,65 @@ function rememberCollision(host, tld, choice, remember) {
|
|||
if (remember !== "no") saveCollisions();
|
||||
}
|
||||
|
||||
// ---- Blocklists (userData/blocklist.json) --------------------------------
|
||||
// A subscribed list can flag a name as dangerous. That is advice, not a chain
|
||||
// rule: the name is still registered, and what happens next is this browser's
|
||||
// policy and the user's call. See Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md.
|
||||
//
|
||||
// The flags come from the indexer process, which reads each list from the
|
||||
// chain (bns-indexer.js). "Continue anyway" is remembered per name.
|
||||
let blockFlags = new Map(); // registered name -> { name, reason, reportTxid, source, ... }
|
||||
let blocklistInfo = { lists: [], at: 0, ok: null, error: null };
|
||||
let blockOverrides = { byName: {} };
|
||||
const blocklistFile = () => path.join(app.getPath("userData"), "blocklist.json");
|
||||
function loadBlockOverrides() {
|
||||
try { if (fs.existsSync(blocklistFile())) blockOverrides = { byName: {}, ...JSON.parse(fs.readFileSync(blocklistFile(), "utf8")) }; }
|
||||
catch (e) { console.error("blocklist overrides load failed:", e.message); }
|
||||
}
|
||||
function saveBlockOverrides() {
|
||||
try { fs.writeFileSync(blocklistFile(), JSON.stringify(blockOverrides, null, 2)); } catch (e) { console.error("blocklist overrides save failed:", e.message); }
|
||||
}
|
||||
// The registered name a host belongs to: a subdomain is governed by its parent.
|
||||
function blockKeyFor(entry, host) {
|
||||
if (entry && entry.name) return String(entry.name).toLowerCase();
|
||||
return String(host || "").toLowerCase().split(".").filter(Boolean).slice(-2).join(".");
|
||||
}
|
||||
// null = open it normally. Otherwise the flag, and whether the user may go on.
|
||||
// Reason "csam" is never offered a way through, whatever the policy.
|
||||
function blockDecision(entry, host) {
|
||||
const policy = settings.blocklistPolicy || "warn";
|
||||
if (policy === "off" || !blockFlags.size) return null;
|
||||
const flag = blockFlags.get(blockKeyFor(entry, host));
|
||||
if (!flag) return null;
|
||||
const canContinue = policy === "warn" && flag.reason !== "csam";
|
||||
if (canContinue && blockOverrides.byName[flag.name]) return null;
|
||||
return { flag, canContinue };
|
||||
}
|
||||
function rememberBlockOverride(name) {
|
||||
const key = String(name || "").toLowerCase();
|
||||
const flag = blockFlags.get(key);
|
||||
// Only for a name that IS flagged, and only where continuing is on offer.
|
||||
if (!flag || flag.reason === "csam" || (settings.blocklistPolicy || "warn") !== "warn") return false;
|
||||
blockOverrides.byName[key] = { at: new Date().toISOString(), reason: flag.reason, report: flag.reportTxid };
|
||||
saveBlockOverrides();
|
||||
return true;
|
||||
}
|
||||
// What the protocol handler answers for a flagged name reached without the
|
||||
// interstitial (a reload, a link, a web app window). Inline, because a
|
||||
// protocol response cannot load one of our own pages — and so with no button:
|
||||
// a page at this origin must not be able to approve itself.
|
||||
function blockedInlineHtml(flag, host) {
|
||||
const esc = (v) => String(v || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Warning: ${esc(flag.name)} is flagged</title>
|
||||
<style>:root{color-scheme:dark}body{margin:0;background:#140b0d;color:#f3e9ea;font:15px/1.55 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh}
|
||||
.card{max-width:560px;padding:28px 32px;background:#1c0f12;border:1px solid #5a2630;border-radius:14px}h1{font-size:20px;margin:0 0 10px;color:#ff8f9c}p{margin:8px 0}
|
||||
small{color:#b98b92}code{font:12px ui-monospace,monospace;word-break:break-all}</style></head>
|
||||
<body><div class="card"><h1>⚠ ${esc(flag.name)} is flagged as dangerous</h1>
|
||||
<p>A blocklist Theseus subscribes to reports this name for <code>${esc(flag.reason)}</code>.</p>
|
||||
<p><small>List <code>${esc(flag.source)}</code> · report <code>${esc(flag.reportTxid)}</code></small></p>
|
||||
<p><small>To decide for yourself, type <b>${esc(host)}</b> in the address bar: Theseus shows the full warning there, with the option to continue where one exists.</small></p></div></body></html>`;
|
||||
}
|
||||
|
||||
function emitEngines() {
|
||||
try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {}
|
||||
if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {}
|
||||
|
|
@ -1849,6 +1914,15 @@ function onIndexerMessage(m) {
|
|||
indexBuiltAt = m.builtAt;
|
||||
} else if (m.type === "status") {
|
||||
bnsIndexStatus = m;
|
||||
} else if (m.type === "blocklist") {
|
||||
// A failed read keeps the last good flags: an unreadable list must not
|
||||
// silently lift a warning, and must never stop a name from resolving.
|
||||
if (m.ok) {
|
||||
blockFlags = new Map((m.flags || []).map((f) => [String(f.name).toLowerCase(), f]));
|
||||
blocklistInfo = { lists: m.lists || [], at: m.at || Date.now(), ok: true, error: null };
|
||||
} else {
|
||||
blocklistInfo = { ...blocklistInfo, ok: false, error: m.error || "unreadable" };
|
||||
}
|
||||
} else if (m.type === "reply") {
|
||||
const settle = indexerPending.get(m.id);
|
||||
if (settle) { indexerPending.delete(m.id); settle(!!m.ok); }
|
||||
|
|
@ -2019,6 +2093,13 @@ async function serveBns(request) {
|
|||
// one that answers "not registered" has already evicted it.
|
||||
if (!rec || (rec.gen !== indexGen && !(rec.entry.provisional && !sharedIndex))) { try { await resolveHost(host); rec = entries.get(host); } catch {} }
|
||||
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
|
||||
// A flagged name the user has not chosen to continue to. loadBns shows the
|
||||
// real interstitial; this catches every way of reaching bns:// that does
|
||||
// not pass through it (reload, back/forward, links, web app windows).
|
||||
{
|
||||
const block = blockDecision(rec.entry, host);
|
||||
if (block) return new Response(blockedInlineHtml(block.flag, host), { status: 403, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
||||
}
|
||||
const r = rec.entry.records;
|
||||
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
|
||||
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
|
||||
|
|
@ -2081,7 +2162,9 @@ async function serveBns(request) {
|
|||
// Secret-free: fetch Sia content from the public gateway (it holds the
|
||||
// keys and owns the subfolder mapping) instead of signing S3 requests
|
||||
// with credentials that must never ship in a public build.
|
||||
const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, {});
|
||||
// "x-bns-policy: client": this browser has applied its own blocklist
|
||||
// policy (above), so the gateway does not put its warning page in the way.
|
||||
const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, { headers: { "x-bns-policy": "client" } });
|
||||
const ct = up.contentType && up.contentType !== "application/octet-stream"
|
||||
? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath);
|
||||
let body = up.buffer;
|
||||
|
|
@ -4247,6 +4330,18 @@ function createTab(initial, opts = {}) {
|
|||
}
|
||||
return navigateTab(id, target + rest);
|
||||
}
|
||||
// "Continue anyway" from the blocklist warning. Same rule as above: only
|
||||
// our own page may ask, or any site could approve itself.
|
||||
if (parsed.protocol === "bns:" && parsed.hostname === "block-choose") {
|
||||
e.preventDefault();
|
||||
if (!isAppPage(wc, "blocked.html")) return;
|
||||
const p = parsed.searchParams;
|
||||
const target = String(p.get("host") || "").toLowerCase();
|
||||
const rest = p.get("resturl") || "/";
|
||||
if (!target) return;
|
||||
if (p.get("choice") === "continue") rememberBlockOverride(p.get("name") || target);
|
||||
return navigateTab(id, target + rest);
|
||||
}
|
||||
// A wiz:// click never navigates — it hands the pairing URI to the
|
||||
// wallet and leaves the dapp exactly where it is.
|
||||
if (parsed.protocol === "wiz:") {
|
||||
|
|
@ -4842,6 +4937,23 @@ async function loadBns(t, id, host, rest, tld) {
|
|||
if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); }
|
||||
}
|
||||
|
||||
// ---- Blocklist ---------------------------------------------------------
|
||||
// A subscribed list flags this name: show the warning instead of the site.
|
||||
// The page posts "continue" back through bns://block-choose/ (will-navigate).
|
||||
const block = blockDecision(entry, host);
|
||||
if (block) {
|
||||
setLoading(t, false);
|
||||
const q = new URLSearchParams({
|
||||
host, name: block.flag.name, reason: block.flag.reason, list: block.flag.source || "",
|
||||
report: block.flag.reportTxid || "", resturl: rest, proceed: block.canContinue ? "1" : "0",
|
||||
}).toString();
|
||||
await t.view.webContents.loadFile(path.join(__dirname, "blocked.html"), { search: q });
|
||||
t.prov = { host, kind: "resolving", tld, registry };
|
||||
if (id === activeId) pushNav(t.prov);
|
||||
emitTabs();
|
||||
return;
|
||||
}
|
||||
|
||||
await t.view.webContents.loadURL(`bns://${host}${rest}`);
|
||||
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
|
||||
// routes via the parent's server, not via Sia. See serveBns for the rule.
|
||||
|
|
@ -6793,6 +6905,22 @@ ipcMain.handle("collision-set-policy", (_e, p) => {
|
|||
return settings.collisionPolicy;
|
||||
});
|
||||
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
|
||||
// Blocklists: what the subscribed lists flag, the policy, and the names the
|
||||
// user chose to continue to.
|
||||
ipcMain.handle("blocklist-state", () => ({
|
||||
policy: settings.blocklistPolicy || "warn",
|
||||
lists: blocklistInfo.lists,
|
||||
readAt: blocklistInfo.at,
|
||||
ok: blocklistInfo.ok,
|
||||
error: blocklistInfo.error,
|
||||
flagged: [...blockFlags.values()].map((f) => ({ name: f.name, reason: f.reason, source: f.source, reportTxid: f.reportTxid })),
|
||||
overrides: Object.keys(blockOverrides.byName),
|
||||
}));
|
||||
ipcMain.handle("blocklist-set-policy", (_e, p) => {
|
||||
if (["warn", "refuse", "off"].includes(p)) { settings.blocklistPolicy = p; saveSettings(); }
|
||||
return settings.blocklistPolicy;
|
||||
});
|
||||
ipcMain.handle("blocklist-reset", () => { blockOverrides = { byName: {} }; saveBlockOverrides(); return true; });
|
||||
// Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe
|
||||
// as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge").
|
||||
// Turning them off means non-Theseus browsers stop resolving BCDN names on
|
||||
|
|
@ -8513,6 +8641,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
|||
loadBookmarks();
|
||||
loadHistory();
|
||||
loadCollisions();
|
||||
loadBlockOverrides();
|
||||
loadWalletPermissions();
|
||||
applyPermissions();
|
||||
applyEmbedCookieShim();
|
||||
|
|
@ -8626,4 +8755,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
|||
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
|
||||
}
|
||||
|
||||
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow };
|
||||
module.exports = {
|
||||
serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow,
|
||||
// dev/blocklist-selftest.js: lets the harness stand in for the indexer
|
||||
// process (which is what normally delivers the flags) and read decisions.
|
||||
_blocklistTest: {
|
||||
setFlags: (flags) => onIndexerMessage({ type: "blocklist", ok: true, at: Date.now(), lists: [], flags }),
|
||||
setPolicy: (p) => { settings.blocklistPolicy = p; },
|
||||
decision: (entry, host) => blockDecision(entry, host),
|
||||
remember: (name) => rememberBlockOverride(name),
|
||||
forget: () => { blockOverrides = { byName: {} }; },
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
"link-status.html",
|
||||
"link-status-preload.js",
|
||||
"collision.html",
|
||||
"blocked.html",
|
||||
"collision-preload.js",
|
||||
"messages.html",
|
||||
"messages-preload.js",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("cfg", {
|
|||
collisionState: () => ipcRenderer.invoke("collision-state"),
|
||||
setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p),
|
||||
resetCollisions: () => ipcRenderer.invoke("collision-reset"),
|
||||
// Blocklists: flagged names, the policy, and the "continue anyway" choices
|
||||
blocklistState: () => ipcRenderer.invoke("blocklist-state"),
|
||||
setBlocklistPolicy: (p) => ipcRenderer.invoke("blocklist-set-policy", p),
|
||||
resetBlocklist: () => ipcRenderer.invoke("blocklist-reset"),
|
||||
// Add-ons management (Settings > Add-ons tab).
|
||||
listAddons: () => ipcRenderer.invoke("addons-list"),
|
||||
setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled),
|
||||
|
|
|
|||
|
|
@ -346,6 +346,17 @@
|
|||
<div id="colSummary" class="pmuted" style="font-size:12.5px"></div>
|
||||
<div><button id="resetCollisions" class="btn">Reset remembered choices</button></div>
|
||||
</div>
|
||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<div class="txt"><div class="t">Flagged names</div>
|
||||
<div class="d">A blocklist is a public, on-chain list of names reported as dangerous: phishing, impersonation, malware, financial scams. A flagged name is still registered; this decides what Theseus does before opening one.</div></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<label class="polrow"><input type="radio" name="blocklistPolicy" value="warn"><span><b>Warn me</b> <span class="pmuted">— a warning page first; you can continue, and Theseus remembers that per name.</span></span></label>
|
||||
<label class="polrow"><input type="radio" name="blocklistPolicy" value="refuse"><span><b>Never open</b> <span class="pmuted">— flagged names are not opened at all.</span></span></label>
|
||||
<label class="polrow"><input type="radio" name="blocklistPolicy" value="off"><span><b>Ignore the lists</b> <span class="pmuted">— no warnings.</span></span></label>
|
||||
</div>
|
||||
<div id="blkSummary" class="pmuted" style="font-size:12.5px"></div>
|
||||
<div><button id="resetBlocklist" class="btn">Forget "continue anyway" choices</button></div>
|
||||
</div>
|
||||
<h2 class="sub">About</h2>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">Theseus Navigator is free software</div>
|
||||
|
|
@ -1746,6 +1757,26 @@
|
|||
C.resetCollisions().then(refreshCollisions);
|
||||
};
|
||||
|
||||
// ---- Naming section: blocklists ----
|
||||
function refreshBlocklist() {
|
||||
C.blocklistState().then((b) => {
|
||||
document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => { r.checked = (r.value === b.policy); });
|
||||
const lists = (b.lists || []).map((l) => l.name || l.address).join(", ") || "none";
|
||||
const n = (b.flagged || []).length, o = (b.overrides || []).length;
|
||||
const read = b.ok === null ? "not read yet"
|
||||
: b.ok ? `read ${new Date(b.readAt).toLocaleTimeString()}`
|
||||
: `could not be read (${b.error || "unreachable"}); the last known flags stay in force`;
|
||||
document.getElementById("blkSummary").textContent =
|
||||
`Subscribed: ${lists} — ${read}. Flagged now: ${n} name${n === 1 ? "" : "s"}. `
|
||||
+ `You chose to continue to ${o} name${o === 1 ? "" : "s"}.`;
|
||||
}).catch(() => {});
|
||||
}
|
||||
refreshBlocklist();
|
||||
document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => {
|
||||
r.addEventListener("change", () => { if (r.checked) C.setBlocklistPolicy(r.value).then(refreshBlocklist); });
|
||||
});
|
||||
document.getElementById("resetBlocklist").onclick = () => { C.resetBlocklist().then(refreshBlocklist); };
|
||||
|
||||
// ---- Developer tools dock position --------------------------------------
|
||||
// The active radio reflects the current setting; changing it just calls
|
||||
// C.set — the F12 handler in main.js reads settings.devToolsDock each
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue