theseus/bcnr-origin.js
2026-09-12 00:07:42 +02:00

103 lines
4.4 KiB
JavaScript

// origin.js — compute the "permission origin" for a page.
//
// Permissions are keyed off the eTLD+1 of the page's URL, not its full origin:
// pay.merchant.com → permission origin merchant.com
// blog.merchant.com → permission origin merchant.com (same as pay.)
// evil.com → permission origin evil.com (different)
// pay.silentmode.bch → permission origin silentmode.bch (BNS root)
//
// This matches how MetaMask, browser cookies (SameSite), and CORS all think of
// origins — the boundary of trust is the registrable domain, not the specific
// subdomain. A dApp that already got "always allow signMessage" on
// checkout.merchant.com should NOT need to re-approve when it navigates to
// account.merchant.com. But evil.com cannot piggyback on merchant.com's grant.
//
// For ICANN TLDs we use the Public Suffix List via the `psl` package — the
// same list Chromium uses — which handles the multi-part cases (.co.uk,
// .github.io, ...). For BNS we key off the passed-in bcnrTlds array so a
// name like foo.wallet gets treated as a public suffix once the on-chain TLD
// list includes "wallet".
//
// The function is pure: no I/O, no imports of Electron. Tested in isolation
// by dev/origin-selftest.mjs.
const psl = require("psl");
// Opaque or non-webby origins that never hold permissions. Returned as
// literal strings so calling code can compare and treat them uniformly.
const OPAQUE = new Set(["data:", "blob:", "javascript:"]);
function isIpLiteral(hostname) {
// Bracketed IPv6 (URL.hostname strips brackets — the check on ':' inside
// catches those) or a bare IPv4.
if (!hostname) return false;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true;
if (hostname.includes(":")) return true;
return false;
}
// Given "pay.silentmode.bch" + bcnrTlds ["bch","wallet"] → "silentmode.bch".
// Given "silentmode.bch" alone → "silentmode.bch". A bare TLD ("bch") returns
// null — no registrable label to key permissions off.
function bnsEtldPlusOne(hostname, bcnrTlds) {
const labels = String(hostname).toLowerCase().split(".").filter(Boolean);
if (labels.length < 2) return null;
const tld = labels[labels.length - 1];
if (!bcnrTlds.includes(tld)) {
// Not a BCNR-native TLD — treat the whole hostname as the origin. Safer
// than pretending we know the suffix; a name like foo.privateTld will
// land here until the TLD list catches up.
return labels.join(".");
}
// BCNR names are one label below the TLD: `{label}.{tld}` is the root
// identity, subdomains extend it. Registrable = label + tld.
return `${labels[labels.length - 2]}.${tld}`;
}
// Main entry. Returns a string origin key, or null for un-resolvable input.
// Strings intentionally include the scheme when it matters (`about:blank`,
// `file://`) so an ICANN eTLD+1 can never collide with a special origin.
function originOf(urlString, { bcnrTlds = ["bch"] } = {}) {
if (!urlString || typeof urlString !== "string") return null;
let u;
try { u = new URL(urlString); } catch { return null; }
if (OPAQUE.has(u.protocol)) return null; // opaque — never gets permissions
if (u.protocol === "about:") {
// about:blank, about:srcdoc — normalize to their canonical form.
return `about:${u.pathname || "blank"}`;
}
if (u.protocol === "file:") return "file://"; // one bucket for all local files
const host = u.hostname.toLowerCase();
if (!host) return null;
// IP literals and localhost: key by scheme + host + port. Common for dev
// servers. A permission granted to http://localhost:3000 does NOT extend
// to :3001 or to another IP.
if (host === "localhost" || isIpLiteral(host)) {
const port = u.port ? `:${u.port}` : "";
return `${u.protocol}//${host}${port}`;
}
if (u.protocol === "bns:") {
return bnsEtldPlusOne(host, bcnrTlds);
}
if (u.protocol === "http:" || u.protocol === "https:") {
const parsed = psl.parse(host);
if (parsed.error || !parsed.domain) {
// Bare TLD, invalid, or a listed public suffix with no registrable
// label above it — no permission origin. Fall back to full host so
// odd cases don't silently share a bucket.
return host;
}
return parsed.domain; // e.g. merchant.com, foo.co.uk, user.github.io
}
// Any other scheme (chrome://, chrome-extension://, ...) — key by scheme +
// host so it doesn't collide with a real origin.
return `${u.protocol}//${host}`;
}
module.exports = { originOf, bnsEtldPlusOne };