From 225a9e261efabd4d033cb3fab5b2be3556899f5c Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 21:48:07 +0200 Subject: [PATCH] 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://, the mapping Theseus already uses for that origin; responses keep Location and Set-Cookie. --- main.js | 67 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/main.js b/main.js index 40669cba..8c458333 100644 --- a/main.js +++ b/main.js @@ -1807,14 +1807,18 @@ function certFp(cert) { const fp = cert && cert.fingerprint256; 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"; if (useTor) await loadSocks(); return new Promise((resolve, reject) => { const opts = { - host: ip, port, servername, method: "GET", path: reqPath, - headers: { host: servername, "user-agent": "theseus/1" }, + host: ip, port, servername, method: init.method || "GET", path: reqPath, + 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. if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`); const req = https.request(opts, (res) => { @@ -1825,39 +1829,68 @@ async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) { } const chunks = []; 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.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"; if (useTor) await loadSocks(); return new Promise((resolve, reject) => { const opts = { - host: ip, port: 80, method: "GET", path: reqPath, - headers: { host: hostHeader, "user-agent": "theseus/1" }, + host: ip, port: 80, method: init.method || "GET", path: reqPath, + 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}`); const req = http.request(opts, (res) => { const chunks = []; 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); }); 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 // 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 // the pin. -async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) { - if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase()); - return await httpGetByIp(ip, reqPath, hostHeader); +async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint, init = {}) { + if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase(), init); + 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://, 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 // 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. const NULL_BODY_STATUS = new Set([101, 204, 205, 304]); function upstreamResponse(up, contentType) { - const headers = { "content-type": contentType }; - if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location; + const headers = new Headers({ "content-type": contentType }); + 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 }); } @@ -2147,7 +2182,7 @@ async function serveBns(request) { // record when available, HTTP fallback when not. Fixes serving BNS names // whose server redirects :80→:443 (the plain-fetch path chokes on the // 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)); }; // `p` — reverse-proxy the request to a full upstream URL. Address bar stays