gateway: warn before serving a flagged name; expose what the lists say

A flagged name and its subdomains are served behind a warning that names the
reason, the list and the report transaction. The visitor may continue, per
name, for a day. The one refusal is the csam reason.

/api/blocklist returns the subscribed lists and current flags, and
/api/name/<name> carries the flag fields, for clients that cannot reach a
full electrum server. It is a convenience copy: every entry names its report
and can be re-derived from the list address.

Flags are read once a minute from full servers and shared across requests;
names are resolved without the extra round trip. BNS_BLOCKLISTS adds lists.

The mobile constants now carry the default subscriptions for the consumer to
come, and the docs explain flagged names.
This commit is contained in:
Local Dev 2026-10-04 02:33:02 +02:00
parent dabe9a9f8e
commit 6e6e79f8db
2 changed files with 130 additions and 5 deletions

View file

@ -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 `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex"><title>Warning: ${escapeHtml(entry.name)} is flagged</title>
<style>body{font:16px/1.55 system-ui,sans-serif;background:#12090b;color:#f3e9ea;display:grid;place-items:center;min-height:100vh;margin:0;padding:24px}
main{max-width:620px}h1{font-size:1.5rem;margin:0 0 .6rem;color:#ff8f9c}code{font-size:.82em;word-break:break-all;color:#e7c9cd}
p{margin:.7rem 0}.box{border:1px solid #5a2630;background:#1c0f12;border-radius:12px;padding:20px 22px}
a.back{display:inline-block;margin-top:14px;padding:10px 18px;border-radius:9px;background:#f3e9ea;color:#12090b;text-decoration:none;font-weight:600}
a.go{display:inline-block;margin:14px 0 0 14px;color:#b98b92;font-size:.9rem}small{color:#b98b92}</style>
<main><div class="box">
<h1>⚠ ${escapeHtml(entry.name)} is flagged as dangerous</h1>
<p>${escapeHtml(BLOCK_REASON_TEXT[reason] || "It was reported as dangerous.")}</p>
<p><small>Reason <code>${escapeHtml(reason)}</code> · list <code>${escapeHtml(entry.blockedBy || "")}</code><br>
report <code>${escapeHtml(entry.blockReportTxid || "")}</code></small></p>
<p><small>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.</small></p>
<a class="back" href="javascript:history.length>1?history.back():location.assign('/')">Go back</a>${proceedHref ? `<a class="go" href="${escapeHtml(proceedHref)}" rel="nofollow">Continue to the site anyway</a>` : ""}
</div></main></html>`;
}
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 <sub>.<label>.<tld>
// Names are resolved without the blocklist round trip; the flags come from
// the gateway's own cached copy of the lists and are attached to a COPY.
const plain = await resolveName(host, { WebSocket, blocklists: [] }); // resolveName normalizes any <sub>.<label>.<tld>
const entry = withBlockFlag(plain, await getBlocklists());
cache.set(host, { entry, at: Date.now() });
return entry;
}
@ -525,7 +593,7 @@ function proxyToUrl(upstreamUrl, reqPath) {
// Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback
// still tries `s3` first as before.
const escapeHtml = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
async function serve(res, name, reqPath, baseHref) {
async function serve(res, name, reqPath, baseHref, req = null) {
// Public content: let pages on other origins (sirius.x, theseus.x) read a
// name's files with fetch(), e.g. Sirius Studio inlining a stylesheet.
res.setHeader("access-control-allow-origin", "*");
@ -535,6 +603,28 @@ async function serve(res, name, reqPath, baseHref) {
let entry;
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); }
if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); }
// A flagged name (and every subdomain of it) is served behind a warning.
// Policy "warn": the visitor may continue, per name, for a day — except
// for "csam", where the gateway refuses outright.
if (entry.blocked) {
const refuse = entry.blockReason === "csam";
const u = new URL(reqPath || "/", "http://x");
const root = baseHref === null ? "" : `${MOUNT}/${name}`; // null baseHref = subdomain form, own origin
if (!refuse && u.searchParams.get("bns-proceed") === "1") {
u.searchParams.delete("bns-proceed");
res.writeHead(302, {
"set-cookie": `${ackCookieName(entry.name)}=1; Path=${root || "/"}; Max-Age=86400; SameSite=Lax`,
"cache-control": "no-store",
location: `${root}${u.pathname}${u.search}`,
});
return res.end();
}
if (refuse || !hasBlockAck(req, entry.name)) {
u.searchParams.set("bns-proceed", "1");
res.writeHead(refuse ? 451 : 403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(blockPage(entry, refuse ? null : `${root}${u.pathname}${u.search}`));
}
}
const r = entry.records;
// A subdomain may be governed by the owner's signed manifest: pointed at a
// different folder, redirected, or switched off. Only fetched when the
@ -1121,6 +1211,31 @@ const server = http.createServer(async (req, res) => {
// --- registrar API: availability of a name ---
// GET /api/name/<name> -> { name, tld, registered, records? }
// --- blocklist API: what the lists this gateway subscribes to flag now ---
// GET /api/blocklist -> { lists: [{ name, address }], count, entries: [flag] }
// A convenience copy for clients that cannot reach a full electrum
// server. It is not the authority: every entry names its report txid and
// can be re-derived from the list address by anyone (reduceBlocklist).
if (req.url === "/api/blocklist" || req.url.startsWith("/api/blocklist?")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
res.setHeader("cache-control", "public, max-age=60");
try {
const index = await getIndex();
const flags = await getBlocklists();
const entries = [...flags.values()].map((f) => ({
name: f.name, reason: f.reason, report_txid: f.reportTxid, txid: f.txid, height: f.height,
evidence: f.evidence ?? null, vote: f.vote ?? null,
lists: f.sources.map((x) => x.source),
})).sort((a, b) => a.name.localeCompare(b.name));
res.writeHead(200);
res.end(JSON.stringify({
lists: SUBSCRIBED_LISTS.map((l) => ({ name: l.name ?? null, address: blocklistAddress(l, index) })),
count: entries.length, entries,
}, null, 2));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
return;
}
if (req.url.startsWith("/api/name/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
@ -1134,12 +1249,16 @@ const server = http.createServer(async (req, res) => {
// height of its release (<= 0 while unconfirmed); it can be
// registered again only from the block after that one.
const releasedAt = index.released?.get(key);
// Advisory flag from the lists this gateway subscribes to. The name
// is still registered; what to do about the flag is the client's call.
const flag = entry ? (await getBlocklists()).get(key) : null;
res.writeHead(200);
res.end(JSON.stringify({
name: key, tld, registered: !!entry,
records: entry?.records ?? null, category: entry?.category ?? null,
owner: entry?.owner ?? null,
...(releasedAt !== undefined ? { released_at: releasedAt, release_pending: !(releasedAt > 0) } : {}),
...(flag ? { blocked: true, block_reason: flag.reason, block_report_txid: flag.reportTxid, blocked_by: flag.source } : {}),
}));
} catch (e) {
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
@ -2105,7 +2224,7 @@ const server = http.createServer(async (req, res) => {
const flat = inner.endsWith(".bch") ? inner.slice(0, -4) : inner;
if (flat) {
const name = flat.replace(/-/g, ".") + ".bch"; // dashes back to dots
return serve(res, name, req.url || "/", null); // own origin: no <base>
return serve(res, name, req.url || "/", null, req); // own origin: no <base>
}
}
// bare apex of SUB_BASE (e.g. bch.navigate.st) -> home
@ -2128,7 +2247,7 @@ const server = http.createServer(async (req, res) => {
// makes `.`, `..`, `foo/`, `../bar/` all resolve as the browser would
// if the site were served from its own origin.
const restDir = rest.replace(/[^/]*$/, "") || "/";
return serve(res, name, rest, `${MOUNT}/${name}${restDir}`);
return serve(res, name, rest, `${MOUNT}/${name}${restDir}`, req);
} catch (e) { res.writeHead(500); res.end("gateway error: " + e.message); }
});
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT}, subdomain *.${SUB_BASE})`));

View file

@ -54,6 +54,12 @@ ${net.electrum.map(javaServer).join("\n")}
public static final String NAME_LIST_URL = ${q(net.nameListUrl)};
public static final String CDN_INDEX_URL = ${q(net.cdnIndexUrl)};
public static final String EXPLORER_TX = ${q(net.explorerTx)};
// Default blocklist subscriptions (PROTOCOL-ADDENDUM-blocklist.md), one
// per row: { list name, baked-in list address, electrum scripthash }.
// The name's "bl" record overrides the address when it is readable.
public static final String[][] BLOCKLISTS = {
${(net.blocklists || []).map((l) => ` { ${q(l.name)}, ${q(l.beacon)}, ${q(l.scripthash)} },`).join("\n")}
};
}
`;
mkdirSync(path.dirname(OUT), { recursive: true });