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:
parent
dabe9a9f8e
commit
6e6e79f8db
2 changed files with 130 additions and 5 deletions
|
|
@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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})`));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue