diff --git a/apps_script/Code.gs b/apps_script/Code.gs index 9d1d5af..51e508a 100644 --- a/apps_script/Code.gs +++ b/apps_script/Code.gs @@ -24,8 +24,15 @@ const SKIP_HEADERS = { // IP-leaking / proxy-metadata headers "x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1, "x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1, + // Internal relay hop-count header — must not be forwarded to target sites. + "x-mhr-hop": 1, }; +// Pattern that matches any Google Apps Script execution endpoint. +// Used to detect relay loops when an exit node is misconfigured to +// point back at a GAS deployment. +var _GAS_URL_RE = /^https?:\/\/script\.google\.com\/macros\//i; + // If fetchAll fails, only retry methods that are safe to replay. const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 }; @@ -48,6 +55,13 @@ function _doSingle(req) { if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { return _json({ e: "bad url" }); } + // Loop guard: refuse to relay back to any Apps Script deployment. + // This fires when an exit node URL is misconfigured to point at a GAS + // script — without this check the script would call itself indefinitely + // and burn through the daily UrlFetch quota in seconds. + if (_GAS_URL_RE.test(req.u)) { + return _json({ e: "loop detected: relay target cannot be a Google Apps Script URL" }); + } var opts = _buildOpts(req); var resp = UrlFetchApp.fetch(req.u, opts); return _json({ @@ -73,6 +87,10 @@ function _doBatch(items) { errorMap[i] = "bad url"; continue; } + if (_GAS_URL_RE.test(item.u)) { + errorMap[i] = "loop detected: relay target cannot be a Google Apps Script URL"; + continue; + } try { var opts = _buildOpts(item); opts.url = item.u; @@ -146,15 +164,21 @@ function _buildOpts(req) { validateHttpsCertificates: true, escaping: false, }; + // Always mark outgoing UrlFetchApp requests with a relay hop counter. + // Exit nodes and downstream relays can inspect this header to detect + // loops before consuming quota or making recursive calls. + var headers = { "x-mhr-hop": "1" }; if (req.h && typeof req.h === "object") { - var headers = {}; for (var k in req.h) { - if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) { + // Use call() so a crafted req.h that overrides hasOwnProperty cannot + // bypass the check (prototype-pollution hardening). + if (Object.prototype.hasOwnProperty.call(req.h, k) && + !SKIP_HEADERS[k.toLowerCase()]) { headers[k] = req.h[k]; } } - opts.headers = headers; } + opts.headers = headers; if (req.b) { opts.payload = Utilities.base64Decode(req.b); if (req.ct) opts.contentType = req.ct; diff --git a/apps_script/cloudflare_worker.js b/apps_script/cloudflare_worker.js index db3762c..58c10c5 100644 --- a/apps_script/cloudflare_worker.js +++ b/apps_script/cloudflare_worker.js @@ -17,6 +17,8 @@ const STRIP_HEADERS = new Set([ "x-real-ip", "forwarded", "via", + // Internal relay hop header — must not propagate to the final target. + "x-mhr-hop", ]); function decodeBase64ToBytes(input) { @@ -88,6 +90,34 @@ export default { 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; diff --git a/src/relay/relay_response.py b/src/relay/relay_response.py index 504b6e4..8248267 100644 --- a/src/relay/relay_response.py +++ b/src/relay/relay_response.py @@ -112,6 +112,16 @@ def classify_relay_error(raw: str) -> str: """ lower = raw.lower() + # Relay loop detected by Code.gs or a Cloudflare Worker exit node. + if "loop detected" in lower or lower == "loop_detected": + return ( + "Relay loop detected. " + "Your exit node URL is misconfigured — it points back to a " + "Google Apps Script deployment or to the Cloudflare Worker itself. " + "Set 'exit_node_url' in config.json to the actual exit node address " + "(Cloudflare Worker, Deno Deploy, or VPS), not to a GAS script URL." + ) + if any(p in lower for p in _QUOTA_PATTERNS): return ( "Apps Script quota exhausted. "