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.")}
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.