104 lines
4.4 KiB
JavaScript
104 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 };
|