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