diff --git a/blocked.html b/blocked.html new file mode 100644 index 00000000..c189ec8c --- /dev/null +++ b/blocked.html @@ -0,0 +1,103 @@ + + + + + Warning + + + + + +
+

⚠ is flagged as dangerous

+

+
+
Reason
+
List
+
Report
+
+

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.

+ +
+ + +
+
+ + + + diff --git a/bns-indexer.js b/bns-indexer.js index 1bad3556..45687f4e 100644 --- a/bns-indexer.js +++ b/bns-indexer.js @@ -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)); } diff --git a/dev/blocklist-selftest.js b/dev/blocklist-selftest.js new file mode 100644 index 00000000..51887108 --- /dev/null +++ b/dev/blocklist-selftest.js @@ -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= +// 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| ({ 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); +}); diff --git a/main.js b/main.js index f21c0b51..80623adf 100644 --- a/main.js +++ b/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, """); + return `Warning: ${esc(flag.name)} is flagged + +

⚠ ${esc(flag.name)} is flagged as dangerous

+

A blocklist Theseus subscribes to reports this name for ${esc(flag.reason)}.

+

List ${esc(flag.source)} · report ${esc(flag.reportTxid)}

+

To decide for yourself, type ${esc(host)} in the address bar: Theseus shows the full warning there, with the option to continue where one exists.

`; +} + 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: {} }; }, + }, +}; diff --git a/package.json b/package.json index 42f332a3..0fe8e66b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "link-status.html", "link-status-preload.js", "collision.html", + "blocked.html", "collision-preload.js", "messages.html", "messages-preload.js", diff --git a/settings-preload.js b/settings-preload.js index c51408d4..97fd9503 100644 --- a/settings-preload.js +++ b/settings-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), diff --git a/settings.html b/settings.html index 064955cf..48966758 100644 --- a/settings.html +++ b/settings.html @@ -346,6 +346,17 @@
+
+
Flagged names
+
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.
+
+ + + +
+
+
+

About

Theseus Navigator is free software
@@ -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