Skip to content

Commit cbb0846

Browse files
therealalephclaude
andcommitted
fix: v1.9.6 — Code.gs/CodeFull.gs hardening, goog.script.init unwrap, README rewrite
Server-side (Apps Script) fixes — users replace their Code.gs with assets/apps_script/Code.gs (or CodeFull.gs for full mode) and Manage deployments → ✏️ → New version → Deploy: - Removed duplicate doGet in Code.gs (HtmlService one was overriding ContentService one due to JS hoisting → every GET to /exec returned a goog.script.init iframe instead of the placeholder HTML) - CodeFull.gs doGet switched from HtmlService to ContentService (same reason) - SKIP_HEADERS now strips X-Forwarded-* / Forwarded / Via family — second line of defense to v1.2.9's client-side stripping (#104), in case a misconfigured upstream proxy adds these - _doBatch fallback when UrlFetchApp.fetchAll() throws as a whole — per-item fetch on safe methods so one bad URL no longer poisons the entire batch (port from masterking32@3094288) Client-side (Rust) defense-in-depth: - parse_relay_json now unwraps goog.script.init("...userHtml...") if any deployment returns the iframe-wrapped form (legacy Code.gs, or a redirect that GETs doGet). New extract_apps_script_user_html + decode_js_string_escapes helpers. Tested against a real deployment's doGet response. Docs: - README rewritten as short bilingual landing page (English + Persian RTL) targeting normal users; advanced reference moved to docs/guide.md + docs/guide.fa.md. Tests: 3 new regression tests. 176 lib + 33 tunnel-node tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d336bd3 commit cbb0846

9 files changed

Lines changed: 1425 additions & 734 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.9.5"
3+
version = "1.9.6"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

README.md

Lines changed: 215 additions & 686 deletions
Large diffs are not rendered by default.

assets/apps_script/Code.gs

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,27 @@ const CACHE_DEFAULT_TTL_SECONDS = 86400; // 24-hour fallback when no Cache-Contr
6161
// real-world Vary usage without inspecting the response.
6262
const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"];
6363

64-
// Keep browser capability headers (sec-ch-ua*, sec-fetch-*) intact.
65-
// Some modern apps, notably Google Meet, use them for browser gating.
64+
// Connection-level + IP-leak request headers we strip before forwarding
65+
// to the destination. Browser capability headers (sec-ch-ua*, sec-fetch-*)
66+
// stay intact — modern apps like Google Meet use them for browser gating.
67+
// We also drop the `X-Forwarded-*` / `Forwarded` / `Via` family so a
68+
// misconfigured upstream proxy on the user side can't leak the user's
69+
// real IP through the relay path. Mirrors upstream
70+
// `masterking32/MasterHttpRelayVPN@3094288`.
6671
const SKIP_HEADERS = {
6772
host: 1, connection: 1, "content-length": 1,
6873
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
6974
"priority": 1, te: 1,
75+
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
76+
"x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1,
7077
};
7178

79+
// Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises.
80+
// GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE
81+
// can have side-effects so we surface the error instead of silently
82+
// re-firing them.
83+
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
84+
7285
// Headers that disqualify a request from the cache path.
7386
const CACHE_BUSTING_HEADERS = {
7487
authorization: 1, cookie: 1, "x-api-key": 1,
@@ -168,37 +181,86 @@ function _doSingle(req) {
168181

169182
function _doBatch(items) {
170183
var fetchArgs = [];
184+
var fetchIndex = [];
185+
var fetchMethods = [];
171186
var errorMap = {};
172187

173188
for (var i = 0; i < items.length; i++) {
174189
var item = items[i];
190+
if (!item || typeof item !== "object") {
191+
errorMap[i] = "bad item";
192+
continue;
193+
}
175194
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
176195
errorMap[i] = "bad url";
177196
continue;
178197
}
179-
var opts = _buildOpts(item);
180-
opts.url = item.u;
181-
fetchArgs.push({ _i: i, _o: opts });
198+
try {
199+
var opts = _buildOpts(item);
200+
opts.url = item.u;
201+
fetchArgs.push(opts);
202+
fetchIndex.push(i);
203+
fetchMethods.push(String(item.m || "GET").toUpperCase());
204+
} catch (buildErr) {
205+
errorMap[i] = String(buildErr);
206+
}
182207
}
183208

184-
// fetchAll() processes all requests in parallel inside Google
209+
// fetchAll() processes all requests in parallel inside Google. If it
210+
// throws as a whole (e.g. one URL violates UrlFetchApp limits and
211+
// poisons the whole batch), degrade to per-item fetch on safe methods
212+
// so a single bad request does not zero out every response in the
213+
// batch. Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`.
185214
var responses = [];
186215
if (fetchArgs.length > 0) {
187-
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; }));
216+
try {
217+
responses = UrlFetchApp.fetchAll(fetchArgs);
218+
} catch (fetchAllErr) {
219+
responses = [];
220+
for (var j = 0; j < fetchArgs.length; j++) {
221+
try {
222+
if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) {
223+
errorMap[fetchIndex[j]] =
224+
"batch fetchAll failed; unsafe method not replayed";
225+
responses[j] = null;
226+
continue;
227+
}
228+
var fallbackReq = fetchArgs[j];
229+
var fallbackUrl = fallbackReq.url;
230+
var fallbackOpts = {};
231+
for (var key in fallbackReq) {
232+
if (
233+
Object.prototype.hasOwnProperty.call(fallbackReq, key) &&
234+
key !== "url"
235+
) {
236+
fallbackOpts[key] = fallbackReq[key];
237+
}
238+
}
239+
responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts);
240+
} catch (singleErr) {
241+
errorMap[fetchIndex[j]] = String(singleErr);
242+
responses[j] = null;
243+
}
244+
}
245+
}
188246
}
189247

190248
var results = [];
191249
var rIdx = 0;
192250
for (var i = 0; i < items.length; i++) {
193-
if (errorMap.hasOwnProperty(i)) {
251+
if (Object.prototype.hasOwnProperty.call(errorMap, i)) {
194252
results.push({ e: errorMap[i] });
195253
} else {
196254
var resp = responses[rIdx++];
197-
results.push({
198-
s: resp.getResponseCode(),
199-
h: _respHeaders(resp),
200-
b: Utilities.base64Encode(resp.getContent()),
201-
});
255+
if (!resp) {
256+
results.push({ e: "fetch failed" });
257+
} else {
258+
results.push({
259+
s: resp.getResponseCode(),
260+
h: _respHeaders(resp),
261+
b: Utilities.base64Encode(resp.getContent()),
262+
});
263+
}
202264
}
203265
}
204266
return _json({ q: results });
@@ -239,15 +301,6 @@ function _respHeaders(resp) {
239301
return resp.getHeaders();
240302
}
241303

242-
function doGet(e) {
243-
return HtmlService.createHtmlOutput(
244-
"<!DOCTYPE html><html><head><title>My App</title></head>" +
245-
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
246-
"<h1>Welcome</h1><p>This application is running normally.</p>" +
247-
"</body></html>"
248-
);
249-
}
250-
251304
function _json(obj) {
252305
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
253306
ContentService.MimeType.JSON

assets/apps_script/CodeFull.gs

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,26 @@ const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY";
2929
// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.)
3030
const DIAGNOSTIC_MODE = false;
3131

32+
// Connection-level + IP-leak request headers we strip before forwarding
33+
// to the destination. UrlFetchApp rejects most of the connection-level
34+
// names anyway, but we also drop the `X-Forwarded-*` / `Forwarded` /
35+
// `Via` family so that a misconfigured upstream proxy on the user side
36+
// can't leak the user's real IP through the relay path. Mirrors
37+
// upstream `masterking32/MasterHttpRelayVPN@3094288`.
3238
const SKIP_HEADERS = {
3339
host: 1, connection: 1, "content-length": 1,
3440
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
3541
"priority": 1, te: 1,
42+
"x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
43+
"x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1,
3644
};
3745

46+
// Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises.
47+
// GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE
48+
// can have side-effects so we surface the error instead of silently
49+
// re-firing them.
50+
const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
51+
3852
// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style
3953
// placeholder page — no proxy-shaped JSON, nothing distinctive enough
4054
// for a probe to fingerprint as a tunnel endpoint.
@@ -279,33 +293,85 @@ function _doSingle(req) {
279293

280294
function _doBatch(items) {
281295
var fetchArgs = [];
296+
var fetchIndex = [];
297+
var fetchMethods = [];
282298
var errorMap = {};
283299
for (var i = 0; i < items.length; i++) {
284300
var item = items[i];
301+
if (!item || typeof item !== "object") {
302+
errorMap[i] = "bad item";
303+
continue;
304+
}
285305
if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
286306
errorMap[i] = "bad url";
287307
continue;
288308
}
289-
var opts = _buildOpts(item);
290-
opts.url = item.u;
291-
fetchArgs.push({ _i: i, _o: opts });
309+
try {
310+
var opts = _buildOpts(item);
311+
opts.url = item.u;
312+
fetchArgs.push(opts);
313+
fetchIndex.push(i);
314+
fetchMethods.push(String(item.m || "GET").toUpperCase());
315+
} catch (buildErr) {
316+
errorMap[i] = String(buildErr);
317+
}
292318
}
319+
320+
// fetchAll() runs all requests in parallel inside Google. If it
321+
// throws as a whole (e.g. one URL violates UrlFetchApp limits and
322+
// poisons the whole batch), degrade to per-item fetch so a single
323+
// bad request does not zero out the entire batch's responses.
324+
// Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`.
293325
var responses = [];
294326
if (fetchArgs.length > 0) {
295-
responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; }));
327+
try {
328+
responses = UrlFetchApp.fetchAll(fetchArgs);
329+
} catch (fetchAllErr) {
330+
responses = [];
331+
for (var j = 0; j < fetchArgs.length; j++) {
332+
try {
333+
if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) {
334+
errorMap[fetchIndex[j]] =
335+
"batch fetchAll failed; unsafe method not replayed";
336+
responses[j] = null;
337+
continue;
338+
}
339+
var fallbackReq = fetchArgs[j];
340+
var fallbackUrl = fallbackReq.url;
341+
var fallbackOpts = {};
342+
for (var key in fallbackReq) {
343+
if (
344+
Object.prototype.hasOwnProperty.call(fallbackReq, key) &&
345+
key !== "url"
346+
) {
347+
fallbackOpts[key] = fallbackReq[key];
348+
}
349+
}
350+
responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts);
351+
} catch (singleErr) {
352+
errorMap[fetchIndex[j]] = String(singleErr);
353+
responses[j] = null;
354+
}
355+
}
356+
}
296357
}
358+
297359
var results = [];
298360
var rIdx = 0;
299361
for (var i = 0; i < items.length; i++) {
300-
if (errorMap.hasOwnProperty(i)) {
362+
if (Object.prototype.hasOwnProperty.call(errorMap, i)) {
301363
results.push({ e: errorMap[i] });
302364
} else {
303365
var resp = responses[rIdx++];
304-
results.push({
305-
s: resp.getResponseCode(),
306-
h: _respHeaders(resp),
307-
b: Utilities.base64Encode(resp.getContent()),
308-
});
366+
if (!resp) {
367+
results.push({ e: "fetch failed" });
368+
} else {
369+
results.push({
370+
s: resp.getResponseCode(),
371+
h: _respHeaders(resp),
372+
b: Utilities.base64Encode(resp.getContent()),
373+
});
374+
}
309375
}
310376
}
311377
return _json({ q: results });
@@ -346,13 +412,17 @@ function _respHeaders(resp) {
346412
return resp.getHeaders();
347413
}
348414

415+
// `doGet` is what active scanners hit first (HTTP GET probes are cheaper
416+
// than POSTs). We use ContentService here so the response body is the
417+
// raw HTML we wrote — `HtmlService.createHtmlOutput` would wrap it in
418+
// a `goog.script.init` sandbox iframe, which the Rust client would then
419+
// see if it ever GET-followed a redirect back onto /macros/.../exec
420+
// (decoy/no-json error path). ContentService keeps the doGet response
421+
// indistinguishable from a forgotten static-HTML web app.
349422
function doGet(e) {
350-
return HtmlService.createHtmlOutput(
351-
"<!DOCTYPE html><html><head><title>My App</title></head>" +
352-
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
353-
"<h1>Welcome</h1><p>This application is running normally.</p>" +
354-
"</body></html>"
355-
);
423+
return ContentService
424+
.createTextOutput(DECOY_HTML)
425+
.setMimeType(ContentService.MimeType.HTML);
356426
}
357427

358428
function _json(obj) {

docs/changelog/v1.9.6.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
2+
• Code.gs / CodeFull.gs hardening + باگ‌فیکس (هیچ تغییری در کانفیگ کاربر لازم نیست — فقط Code.gs خودتان را با [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (یا `CodeFull.gs` برای حالت full) جایگزین کنید + در Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Deployment ID همان قبلی می‌ماند):
3+
- **`Code.gs` doGet تکراری حذف شد**: نسخه‌ای که با `HtmlService.createHtmlOutput` تعریف شده بود به‌خاطر hoisting جاوااسکریپت روی نسخهٔ صحیح `ContentService` overwrite می‌کرد. در نتیجه هر GET به URL deployment پاسخ سندباکس `goog.script.init` iframe برمی‌گرداند به‌جای HTML پلیس‌هولدر ساده. این برای ترافیک معمولی POST تأثیری نداشت ولی در زنجیرهٔ redirect که با GET پی می‌گیریم می‌توانست باگ ظاهر شود.
4+
- **`CodeFull.gs` `doGet` به `ContentService` تغییر کرد** (قبلاً `HtmlService` بود) — به همان دلیل بالا.
5+
- **هدرهای IP-leak در `SKIP_HEADERS` اضافه شد** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — در صورت misconfigured بودن یک پروکسی upstream سمت کاربر، IP واقعی کاربر دیگر در leg دوم سرور به مقصد نشت نمی‌کند. لایهٔ دفاع دوم به stripping سمت کلاینت v1.2.9 (#104).
6+
- **`_doBatch` دارای fallback شد**: اگر `UrlFetchApp.fetchAll()` به‌عنوان یک کل throw کند (مثلاً یک URL بد همه را poison کند)، حالا برای متدهای امن (GET / HEAD / OPTIONS) per-item fetch می‌کند به‌جای صفر کردن کل پاسخ batch. port از `masterking32/MasterHttpRelayVPN@3094288`.
7+
`parse_relay_json` (سمت Rust): unwrapper برای `goog.script.init("...userHtml...")` اضافه شد — اگر هر deployment‌ای پاسخ HtmlService-wrapped برگرداند (legacy Code.gs قبل از v1.9.6، یا redirect که doGet را GET بزند)، client حالا JSON داخلی را استخراج می‌کند به‌جای `key must be a string at line 2 column 1` fail کردن. در مقابل پاسخ doGet واقعی deployment کاربر تست شده — UTF-8 با `\xNN` byte-escape را درست decode می‌کند.
8+
• README بازنویسی شد: نسخهٔ کوتاه دوزبانه (انگلیسی + فارسی RTL) برای کاربر معمولی + راهنمای کامل پیشرفته در [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) و [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). جدا کردن "راه‌اندازی ۵ دقیقه‌ای" از "همهٔ گزینه‌ها و troubleshooting" راهنما را خیلی قابل‌فهم‌تر کرد، خصوصاً برای کاربرانی که می‌خواهند فقط شروع کنند.
9+
• تست: ۳ regression test جدید برای `extract_apps_script_user_html` + `decode_js_string_escapes` + `parse_relay_json` end-to-end. **۱۷۶ lib test + ۳۳ tunnel-node test همه pass.**
10+
---
11+
• Code.gs / CodeFull.gs hardening + bug fixes (no client config change needed — just replace your own Code.gs with [`assets/apps_script/Code.gs`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/Code.gs) (or `CodeFull.gs` for full mode) and in the Apps Script editor: `Manage deployments → ✏️ → Version: New version → Deploy`. Your Deployment ID stays the same):
12+
- **Removed duplicate `doGet` in `Code.gs`**: a second copy declared with `HtmlService.createHtmlOutput` was silently overriding the correct `ContentService` one due to JS function hoisting. Result: every GET to the deployment URL was returning the `goog.script.init` sandbox iframe instead of the simple placeholder HTML. Did not affect normal POST traffic, but could surface during redirect chains we GET-follow.
13+
- **`CodeFull.gs` `doGet` switched to `ContentService`** (was `HtmlService`) — same reason as above.
14+
- **Added IP-leak headers to `SKIP_HEADERS`** (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`, `X-Real-IP`, `Forwarded`, `Via`) — if a misconfigured upstream proxy on the user side adds these, the user's real IP no longer leaks to the destination on the server-side leg. Second line of defense to v1.2.9's client-side stripping (#104).
15+
- **`_doBatch` got a fallback path**: if `UrlFetchApp.fetchAll()` throws as a whole (e.g. one bad URL poisons the batch), it now per-item-fetches safe methods (GET / HEAD / OPTIONS) instead of zeroing the entire batch's responses. Ported from `masterking32/MasterHttpRelayVPN@3094288`.
16+
`parse_relay_json` (Rust client): added unwrapper for `goog.script.init("...userHtml...")` iframe — if any deployment ever returns an HtmlService-wrapped response (legacy Code.gs prior to v1.9.6, or a redirect that GET-hits doGet), the client now extracts the inner JSON instead of failing with `key must be a string at line 2 column 1`. Tested against a real user deployment's actual doGet output — correctly decodes UTF-8 with `\xNN` byte-escapes.
17+
• Rewrote the README: short bilingual landing page (English + Persian RTL) for normal users, with the full advanced reference moved to [`docs/guide.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) and [`docs/guide.fa.md`](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md). Splitting "5-minute quick start" from "every option + troubleshooting" makes the docs much more approachable, especially for users who just want to get running.
18+
• Tests: 3 new regression tests for `extract_apps_script_user_html` + `decode_js_string_escapes` + `parse_relay_json` end-to-end. **176 lib tests + 33 tunnel-node tests all passing.**

0 commit comments

Comments
 (0)