diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index d77ddb9..1403e27 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -28,7 +28,7 @@ import WebSocket from "ws"; // resolver-web.js lives at ../lib/, not alongside this file — the previous // `./resolver-web.js` import was broken (file was moved into lib/ but this // import wasn't updated). Fix while touching the file. -import { resolveName, buildIndex, normalizeName, connectAny, NETWORK, requireBeacons } from "./resolver-web.js"; +import { resolveName, buildIndex, normalizeName, connectAny, NETWORK, requireBeacons, buildBlocklists, blocklistAddress, withBlockFlag } from "./resolver-web.js"; // Everything chain-specific comes from the network table (BNS_NETWORK env; // chipnet unless set). Refuse to start on a network whose beacons are unpinned. requireBeacons(NETWORK); @@ -313,11 +313,79 @@ const aws = new AwsClient({ // disk alongside the beacon snapshot. const _dnsSeqCache = new Map(); +// ---- blocklists (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md) ---- +// Advisory, resolver-side: a flagged name is still registered and still +// served, behind a warning the visitor can dismiss. The gateway subscribes to +// the network's default lists; BNS_BLOCKLISTS (JSON: [{"name","beacon"}]) adds +// more. Read from full electrum servers only — the beacon-only indexers answer +// "no history" for a list address, which would read as an empty list. +const SUBSCRIBED_LISTS = [ + ...(NETWORK.blocklists || []), + ...(() => { try { const j = JSON.parse(process.env.BNS_BLOCKLISTS || "[]"); return Array.isArray(j) ? j : []; } catch { return []; } })(), +]; +let _blocklists = { at: 0, flags: null, loading: null }; +async function getBlocklists() { + if (_blocklists.flags && Date.now() - _blocklists.at < 60000) return _blocklists.flags; + if (!SUBSCRIBED_LISTS.length) { _blocklists = { at: Date.now(), flags: new Map(), loading: null }; return _blocklists.flags; } + // One read at a time: requests that arrive while it runs share its result. + if (!_blocklists.loading) { + _blocklists.loading = (async () => { + try { + const flags = await buildBlocklists({ lists: SUBSCRIBED_LISTS, index: await getIndex(), WebSocket, electrum: FULL_ELECTRUM }); + _blocklists = { at: Date.now(), flags, loading: null }; + } catch { + // An unreadable list must never stop a name from being served. Keep + // the last good answer; with none, behave as if the lists were empty. + _blocklists = { at: Date.now(), flags: _blocklists.flags || new Map(), loading: null }; + } + return _blocklists.flags; + })(); + } + return _blocklists.loading; +} +const BLOCK_REASON_TEXT = { + "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.", +}; +const ackCookieName = (name) => "bns_ack_" + String(name).replace(/[^a-z0-9]/g, "_"); +function hasBlockAck(req, name) { + const raw = String(req?.headers?.cookie || ""); + return raw.split(/;\s*/).some((kv) => kv === ackCookieName(name) + "=1"); +} +// The warning shown in place of a flagged name's site. `proceedHref` is null +// when the visitor is not offered a way through (reason "csam"). +function blockPage(entry, proceedHref) { + const reason = entry.blockReason; + return ` +Warning: ${escapeHtml(entry.name)} is flagged + +
+

⚠ ${escapeHtml(entry.name)} is flagged as dangerous

+

${escapeHtml(BLOCK_REASON_TEXT[reason] || "It was reported as dangerous.")}

+

Reason ${escapeHtml(reason)} · list ${escapeHtml(entry.blockedBy || "")}
+report ${escapeHtml(entry.blockReportTxid || "")}

+

The name is still registered on chain. This warning comes from a list this gateway subscribes to; +a resolver that does not subscribe shows the site without it. Anyone can read the report by its transaction id.

+Go back${proceedHref ? `Continue to the site anyway` : ""} +
`; +} + const cache = new Map(); async function resolve(host) { const c = cache.get(host); if (c && Date.now() - c.at < 30000) return c.entry; - const entry = await resolveName(host, { WebSocket }); // resolveName normalizes any .