mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
feat: implement relay loop detection in Apps Script and Cloudflare Worker
This commit is contained in:
+27
-3
@@ -24,8 +24,15 @@ const SKIP_HEADERS = {
|
|||||||
// IP-leaking / proxy-metadata headers
|
// IP-leaking / proxy-metadata headers
|
||||||
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
|
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
|
||||||
"x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 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.
|
// If fetchAll fails, only retry methods that are safe to replay.
|
||||||
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
|
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)) {
|
if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
|
||||||
return _json({ e: "bad url" });
|
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 opts = _buildOpts(req);
|
||||||
var resp = UrlFetchApp.fetch(req.u, opts);
|
var resp = UrlFetchApp.fetch(req.u, opts);
|
||||||
return _json({
|
return _json({
|
||||||
@@ -73,6 +87,10 @@ function _doBatch(items) {
|
|||||||
errorMap[i] = "bad url";
|
errorMap[i] = "bad url";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (_GAS_URL_RE.test(item.u)) {
|
||||||
|
errorMap[i] = "loop detected: relay target cannot be a Google Apps Script URL";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var opts = _buildOpts(item);
|
var opts = _buildOpts(item);
|
||||||
opts.url = item.u;
|
opts.url = item.u;
|
||||||
@@ -146,15 +164,21 @@ function _buildOpts(req) {
|
|||||||
validateHttpsCertificates: true,
|
validateHttpsCertificates: true,
|
||||||
escaping: false,
|
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") {
|
if (req.h && typeof req.h === "object") {
|
||||||
var headers = {};
|
|
||||||
for (var k in req.h) {
|
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];
|
headers[k] = req.h[k];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
opts.headers = headers;
|
|
||||||
}
|
}
|
||||||
|
opts.headers = headers;
|
||||||
if (req.b) {
|
if (req.b) {
|
||||||
opts.payload = Utilities.base64Decode(req.b);
|
opts.payload = Utilities.base64Decode(req.b);
|
||||||
if (req.ct) opts.contentType = req.ct;
|
if (req.ct) opts.contentType = req.ct;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const STRIP_HEADERS = new Set([
|
|||||||
"x-real-ip",
|
"x-real-ip",
|
||||||
"forwarded",
|
"forwarded",
|
||||||
"via",
|
"via",
|
||||||
|
// Internal relay hop header — must not propagate to the final target.
|
||||||
|
"x-mhr-hop",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function decodeBase64ToBytes(input) {
|
function decodeBase64ToBytes(input) {
|
||||||
@@ -88,6 +90,34 @@ export default {
|
|||||||
if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 });
|
if (k !== PSK) return Response.json({ e: "unauthorized" }, { status: 401 });
|
||||||
if (!/^https?:\/\//i.test(u)) return Response.json({ e: "bad_url" }, { status: 400 });
|
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;
|
let payload;
|
||||||
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
|
if (typeof b64 === "string" && b64.length > 0) payload = decodeBase64ToBytes(b64);
|
||||||
const requestBody = payload ? Uint8Array.from(payload) : undefined;
|
const requestBody = payload ? Uint8Array.from(payload) : undefined;
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ def classify_relay_error(raw: str) -> str:
|
|||||||
"""
|
"""
|
||||||
lower = raw.lower()
|
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):
|
if any(p in lower for p in _QUOTA_PATTERNS):
|
||||||
return (
|
return (
|
||||||
"Apps Script quota exhausted. "
|
"Apps Script quota exhausted. "
|
||||||
|
|||||||
Reference in New Issue
Block a user