// MasterHttpRelay exit node for Cloudflare Workers. // Deploy as HTTP endpoint and set PSK to a strong secret. const PSK = "CHANGE_ME_TO_A_STRONG_SECRET"; const STRIP_HEADERS = new Set([ "host", "connection", "content-length", "transfer-encoding", "proxy-connection", "proxy-authorization", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port", "x-real-ip", "forwarded", "via", // Internal relay hop header — must not propagate to the final target. "x-mhr-hop", // Workers cannot decompress gzip/br/deflate — stripping accept-encoding // forces targets to reply with plain bodies the Worker can forward as-is. "accept-encoding", ]); function decodeBase64ToBytes(input) { const bin = atob(input); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function encodeBytesToBase64(bytes) { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function sanitizeHeaders(h) { const out = {}; if (!h || typeof h !== "object") return out; for (const [k, v] of Object.entries(h)) { if (!k) continue; if (STRIP_HEADERS.has(k.toLowerCase())) continue; out[k] = String(v ?? ""); } return out; } export default { async fetch(req) { try { // Cloudflare dashboard and browsers commonly test a Worker with GET. // Return a friendly health response so users don't misread it as failure. if (req.method === "GET") { return Response.json( { ok: true, status: "healthy", message: "Everything is OK. Worker is deployed and reachable.", usage: "Send POST with relay payload for actual proxy requests.", }, { status: 200 } ); } if (req.method !== "POST") { return Response.json( { e: "method_not_allowed", message: "Use POST for relay requests. GET is only a health check.", }, { status: 405 } ); } const body = await req.json(); if (!body || typeof body !== "object") { return Response.json({ e: "bad_json" }, { status: 400 }); } if (!PSK) { return Response.json({ e: "server_psk_missing" }, { status: 500 }); } const k = String(body.k ?? ""); const u = String(body.u ?? ""); const m = String(body.m ?? "GET").toUpperCase(); const h = sanitizeHeaders(body.h); const b64 = body.b; if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 }); if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 }); // ── Loop detection ──────────────────────────────────────────────────── // Case 1 — self-loop: target URL resolves back to this Worker. // Happens when a user sets exit_node_url to the Worker URL itself. try { const targetHost = new URL(u).hostname.toLowerCase(); const workerHost = new URL(req.url).hostname.toLowerCase(); if (targetHost === workerHost) { return Response.json( { e: "loop_detected", detail: "target URL resolves to this Worker" }, { status: 508 } ); } } catch (_) { // Malformed URL already caught by the regex above; ignore parse errors. } // Case 2 — GAS→Worker→GAS loop: the incoming request was relayed from // a Google Apps Script instance (x-mhr-hop header set), and the // target is another Apps Script script URL. // Without this guard, a misconfigured chain would bounce between // Apps Script and Cloudflare until quota is exhausted. const hopHeader = req.headers.get("x-mhr-hop"); if (hopHeader && /\/macros\/s\//i.test(u)) { return Response.json( { e: "loop_detected", detail: "GAS→Worker→GAS relay loop" }, { status: 508 } ); } let payload; if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64); const requestBody = payload ? Uint8Array.from(payload) : undefined; const resp = await fetch(u, { method: m, headers: h, body: requestBody, redirect: "manual", }); const data = new Uint8Array(await resp.arrayBuffer()); const respHeaders = {}; resp.headers.forEach((value, key) => { respHeaders[key] = value; }); return Response.json({ s: resp.status, h: respHeaders, b: encodeBytesToBase64(data), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return Response.json({ e: message }, { status: 500 }); } }, };