BNS ip-record sites: forward the page's method, body and headers

Theseus fetched pinned ip-record sites with a bare GET whatever the page
asked for, so forms and JSON POSTs on sites like hephaestus.x or
id.theseus.x never reached the server, and redirects and cookies were
dropped on the way back. Requests now carry the method, body and the
headers a site needs (content type, auth, its own cookies, x-* headers),
with the page's bns:// origin presented as https://<name>, the mapping
Theseus already uses for that origin; responses keep Location and
Set-Cookie.
This commit is contained in:
Local Dev 2026-10-04 21:48:07 +02:00
parent b8529f4a7f
commit 225a9e261e

67
main.js
View file

@ -1807,14 +1807,18 @@ function certFp(cert) {
const fp = cert && cert.fingerprint256; const fp = cert && cert.fingerprint256;
return fp ? fp.toLowerCase().replace(/:/g, "") : ""; return fp ? fp.toLowerCase().replace(/:/g, "") : "";
} }
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) { // init: { method, headers, body } for a page's own request (forms, fetch POSTs);
// a bare call is the GET it always was. Headers are already filtered by the
// caller (forwardHeaders); host and user-agent are always ours.
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp, init = {}) {
const useTor = torState === "on"; const useTor = torState === "on";
if (useTor) await loadSocks(); if (useTor) await loadSocks();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const opts = { const opts = {
host: ip, port, servername, method: "GET", path: reqPath, host: ip, port, servername, method: init.method || "GET", path: reqPath,
headers: { host: servername, "user-agent": "theseus/1" }, headers: { ...(init.headers || {}), host: servername, "user-agent": "theseus/1" },
}; };
if (init.body) opts.headers["content-length"] = String(init.body.length);
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate. opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = https.request(opts, (res) => { const req = https.request(opts, (res) => {
@ -1825,39 +1829,68 @@ async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
} }
const chunks = []; const chunks = [];
res.on("data", (c) => chunks.push(c)); res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
}); });
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); }); req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
req.on("error", reject); req.on("error", reject);
req.end(); req.end(init.body || undefined);
}); });
} }
async function httpGetByIp(ip, reqPath, hostHeader) { async function httpGetByIp(ip, reqPath, hostHeader, init = {}) {
const useTor = torState === "on"; const useTor = torState === "on";
if (useTor) await loadSocks(); if (useTor) await loadSocks();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const opts = { const opts = {
host: ip, port: 80, method: "GET", path: reqPath, host: ip, port: 80, method: init.method || "GET", path: reqPath,
headers: { host: hostHeader, "user-agent": "theseus/1" }, headers: { ...(init.headers || {}), host: hostHeader, "user-agent": "theseus/1" },
}; };
if (init.body) opts.headers["content-length"] = String(init.body.length);
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = http.request(opts, (res) => { const req = http.request(opts, (res) => {
const chunks = []; const chunks = [];
res.on("data", (c) => chunks.push(c)); res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) })); res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
res.on("error", reject); res.on("error", reject);
}); });
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout"))); req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
req.on("error", reject); req.end(); req.on("error", reject); req.end(init.body || undefined);
}); });
} }
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else // Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means // plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat // "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin. // the pin.
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) { async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint, init = {}) {
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase()); if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase(), init);
return await httpGetByIp(ip, reqPath, hostHeader); return await httpGetByIp(ip, reqPath, hostHeader, init);
}
// What of a page's own request reaches its ip-record server: method, body
// and the headers a site needs to answer it (forms, JSON APIs, its own
// cookies). The page's bns:// origin is presented as https://<name>, the
// same mapping Theseus uses for that origin everywhere else (pageOriginOf),
// so a server can check Origin like any other site.
// x-*: a site's own custom request headers (CSRF tokens and the like).
const FORWARD_HEADERS = ["accept", "accept-language", "content-type", "authorization", "cookie", "origin", "if-none-match", "if-modified-since", "range"];
const FORWARD_HEADER_RE = /^x-[a-z0-9-]{1,40}$/;
async function forwardInit(request) {
const method = String(request.method || "GET").toUpperCase();
const headers = {};
for (const k of FORWARD_HEADERS) {
let v = request.headers.get(k);
if (v == null) continue;
if (k === "origin") v = v.replace(/^bns:\/\//i, "https://");
headers[k] = v;
}
for (const [k, v] of request.headers.entries()) if (FORWARD_HEADER_RE.test(k.toLowerCase())) headers[k.toLowerCase()] = v;
let body = null;
if (method !== "GET" && method !== "HEAD") {
const buf = Buffer.from(await request.arrayBuffer());
if (buf.length > 8 * 1024 * 1024) throw new Error("request body too large");
body = buf;
}
return { method, headers, body };
} }
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML // OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form. // search template into our { name, url-with-%s } form.
@ -2089,8 +2122,10 @@ const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application
// upstream 204/304 into the 502 page. // upstream 204/304 into the 502 page.
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]); const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
function upstreamResponse(up, contentType) { function upstreamResponse(up, contentType) {
const headers = { "content-type": contentType }; const headers = new Headers({ "content-type": contentType });
if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location; if (up.location && up.status >= 300 && up.status < 400) headers.set("location", up.location);
// The site's own cookies (sessions on ip-record sites) come back to its bns:// origin.
for (const c of up.setCookie || []) headers.append("set-cookie", c);
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers }); return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
} }
@ -2147,7 +2182,7 @@ async function serveBns(request) {
// record when available, HTTP fallback when not. Fixes serving BNS names // record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the // whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS). // redirect target because it isn't in ICANN DNS).
const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls); const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls, await forwardInit(request));
return upstreamResponse(up, up.contentType || guessType(reqPath)); return upstreamResponse(up, up.contentType || guessType(reqPath));
}; };
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays // `p` — reverse-proxy the request to a full upstream URL. Address bar stays