Skip to content

Commit b6806cf

Browse files
committed
fix(config): reject non-loopback proxy binds
The proxy listener is a local ingress point for both HTTP and SOCKS traffic. Binding that listener to a wildcard address or LAN address exposes the relay surface to other devices on the network. Without inbound proxy authentication, that exposure can turn a local client into an unauthenticated shared proxy and allow unrelated devices to consume the operator's Apps Script quota. The default listen_host is now 127.0.0.1, matching the local-only behavior expected for a desktop proxy. Config validation now accepts IPv4 loopback, IPv6 loopback, bracketed IPv6 loopback, and localhost. It rejects wildcard binds, LAN addresses, public hostnames, and other non-loopback values with a hard configuration error before any listener socket is opened. The guard is implemented at configuration validation time rather than at bind time so TOML loading, JSON migration, CLI startup, and UI save paths all observe the same fail-closed rule. Existing explicit loopback profiles continue to load unchanged. Profiles that rely on 0.0.0.0 or a LAN address must wait for an authenticated LAN-sharing mode rather than silently opening an unauthenticated listener. TOML examples now show the loopback listener and call out that non-loopback binds are rejected until inbound proxy authentication exists. The English and Persian guides no longer instruct users to set listen_host to 0.0.0.0 for hotspot or OpenWRT sharing; they describe the current local-only safety behavior instead. Focused config tests cover the repaired default, accepted loopback forms, rejected wildcard and non-loopback forms, TOML network defaults, and JSON-to-TOML migration preserving the loopback listen_host.
1 parent 40b5386 commit b6806cf

8 files changed

Lines changed: 96 additions & 10 deletions

config.direct.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mode = "direct"
44
[network]
55
google_ip = "216.239.38.120"
66
front_domain = "www.google.com"
7+
# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented.
78
listen_host = "127.0.0.1"
89
listen_port = 8085
910
socks5_port = 8086

config.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET"
66
[network]
77
google_ip = "216.239.38.120"
88
front_domain = "www.google.com"
9+
# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented.
910
listen_host = "127.0.0.1"
1011
listen_port = 8085
1112
socks5_port = 8086

config.exit-node.example.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ auth_key = "PUT_YOUR_APPS_SCRIPT_AUTH_KEY_HERE"
99
[network]
1010
google_ip = "216.239.38.120"
1111
front_domain = "www.google.com"
12-
listen_host = "0.0.0.0"
12+
# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented.
13+
listen_host = "127.0.0.1"
1314
listen_port = 8085
1415
socks5_port = 8086
1516
verify_ssl = true
@@ -44,4 +45,4 @@ hosts = [
4445
"openai.com",
4546
"aistudio.google.com",
4647
"ai.google.dev",
47-
]
48+
]

config.fronting-groups.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mode = "direct"
44
[network]
55
google_ip = "216.239.38.120"
66
front_domain = "www.google.com"
7+
# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented.
78
listen_host = "127.0.0.1"
89
listen_port = 8085
910
socks5_port = 8086

config.full.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ auth_key = "CHANGE_ME_TO_A_STRONG_SECRET"
66
[network]
77
google_ip = "216.239.38.120"
88
front_domain = "www.google.com"
9+
# Non-loopback binds such as 0.0.0.0 are rejected until inbound proxy authentication is implemented.
910
listen_host = "127.0.0.1"
1011
listen_port = 8085
1112
socks5_port = 8086

docs/guide.fa.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی
269269

270270
## اشتراک‌گذاری هات‌اسپات
271271

272-
mhrv-rs به‌طور پیش‌فرض روی `0.0.0.0` گوش می‌دهد، پس هر دستگاه روی همان شبکه می‌تواند ازش استفاده کند. سناریوی رایج: اشتراک تونل از گوشی اندروید به آیفون / آیپد / لپ‌تاپ از هات‌اسپات:
272+
mhrv-rs حالا به‌طور پیش‌فرض فقط روی `127.0.0.1` گوش می‌دهد و تا وقتی احراز هویت HTTP/SOCKS برای ورودی پیاده‌سازی نشده باشد، bind غیر loopback را رد می‌کند. این کار جلوی open proxy ناخواسته و مصرف شدن quota Apps Script توسط دستگاه‌های دیگر شبکه را می‌گیرد.
273+
274+
اشتراک‌گذاری هات‌اسپات/LAN بعداً به‌صورت یک حالت صریح و دارای احراز هویت برمی‌گردد. در نسخهٔ فعلی `listen_host` را به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ fail-closed می‌شود. workflow قدیمی این بود:
273275

274276
۱. **اندروید:** هات‌اسپات موبایل را روشن کن + اپ را استارت کن
275277
۲. **دستگاه دیگر:** به Wi-Fi هات‌اسپات اندروید وصل شو
@@ -287,7 +289,7 @@ Settings → Wi-Fi → روی (i) شبکهٔ هات‌اسپات بزن → Conf
287289

288290
HTTP proxy سیستم را روی `192.168.43.1:8080` بگذار، یا per-app SOCKS5 روی `192.168.43.1:1081`.
289291

290-
> اگر `listen_host` در کانفیگت `127.0.0.1` است، به `0.0.0.0` تغییرش بده تا اتصال از دستگاه‌های دیگر را بپذیرد.
292+
> گیت امنیتی فعلی: مقدارهای غیر loopback مثل `0.0.0.0`، `::` یا IP شبکهٔ LAN تا زمان اضافه شدن احراز هویت proxy رد می‌شوند.
291293
292294
## اجرا روی OpenWRT
293295

@@ -306,7 +308,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs
306308
logread -e mhrv-rs -f # تمام لاگ
307309
```
308310

309-
دستگاه‌های LAN HTTP proxy را روی IP روتر (پورت پیش‌فرض `8085`) یا SOCKS5 روی `<router-ip>:8086` تنظیم می‌کنند. در `/etc/mhrv-rs/config.toml` مقدار `listen_host` را به `0.0.0.0` بگذار تا روتر اتصال LAN را بپذیرد.
311+
نسخهٔ فعلی فقط روی loopback گوش می‌دهد. اجرای CLI روی OpenWRT برای تست محلی همچنان کار می‌کند، اما استفاده از روتر به‌عنوان proxy برای کل LAN به حالت authenticated LAN-sharing آینده نیاز دارد. در این نسخه `listen_host` را در `/etc/mhrv-rs/config.toml` به `0.0.0.0` تغییر نده؛ اعتبارسنجی کانفیگ این bind ناامن را رد می‌کند.
310312

311313
مصرف حافظه ~۱۵–۲۰ مگابایت — روی هر روتری با ۱۲۸ مگابایت RAM به بالا اجرا می‌شود. UI روی musl نیست (روترها headlessاند).
312314

docs/guide.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ The destination sees the exit node's IP, not Google's, so the anti-bot heuristic
269269

270270
## Sharing via hotspot
271271

272-
mhrv-rs listens on `0.0.0.0` by default, so any device on the same network can use it. Common scenario: share the tunnel from an Android phone to an iPhone, iPad, or laptop over hotspot:
272+
mhrv-rs listens on `127.0.0.1` by default and rejects non-loopback proxy binds until inbound HTTP/SOCKS authentication is implemented. This prevents accidental open-proxy exposure and Apps Script quota theft on shared Wi-Fi or hotspots.
273+
274+
Hotspot/LAN sharing will return as an explicit authenticated mode in a later release. On current builds, do not change `listen_host` to `0.0.0.0`; startup validation will fail closed. The old sharing workflow was:
273275

274276
1. **Android:** enable mobile hotspot + start the app
275277
2. **Other device:** connect to the Android hotspot Wi-Fi
@@ -287,7 +289,7 @@ For full device-wide coverage on iOS, use [Shadowrocket](https://apps.apple.com/
287289

288290
Set system HTTP proxy to `192.168.43.1:8080`, or per-app SOCKS5 to `192.168.43.1:1081`.
289291

290-
> If `listen_host` is `127.0.0.1` in your config, change to `0.0.0.0` to allow other devices.
292+
> Current safety gate: non-loopback values such as `0.0.0.0`, `::`, or a LAN IP are rejected until proxy authentication is available.
291293
292294
## Running on OpenWRT
293295

@@ -306,7 +308,7 @@ chmod +x /usr/bin/mhrv-rs /etc/init.d/mhrv-rs
306308
logread -e mhrv-rs -f # tail logs
307309
```
308310

309-
LAN devices then point HTTP proxy at the router's LAN IP (default port `8085`) or SOCKS5 at `<router-ip>:8086`. Set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` so the router accepts LAN connections.
311+
Current builds listen on loopback only. Running the CLI on OpenWRT for local diagnostics still works, but using the router as a LAN-wide proxy requires the upcoming authenticated LAN-sharing mode. Do not set `listen_host` to `0.0.0.0` in `/etc/mhrv-rs/config.toml` on this version; config validation will reject the unsafe bind.
310312

311313
Memory footprint ~15–20 MB resident — fine on anything ≥128 MB RAM. No UI on musl (routers are headless).
312314

src/config.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ fn default_front_domain() -> String {
538538
"www.google.com".into()
539539
}
540540
fn default_listen_host() -> String {
541-
"0.0.0.0".into()
541+
"127.0.0.1".into()
542542
}
543543
fn default_listen_port() -> u16 {
544544
8085
@@ -550,6 +550,22 @@ fn default_verify_ssl() -> bool {
550550
true
551551
}
552552

553+
fn is_loopback_listen_host(host: &str) -> bool {
554+
let host = host.trim();
555+
if host.eq_ignore_ascii_case("localhost") {
556+
return true;
557+
}
558+
559+
let host = host
560+
.strip_prefix('[')
561+
.and_then(|h| h.strip_suffix(']'))
562+
.unwrap_or(host);
563+
564+
host.parse::<std::net::IpAddr>()
565+
.map(|ip| ip.is_loopback())
566+
.unwrap_or(false)
567+
}
568+
553569
impl Config {
554570
pub fn load(path: &Path) -> Result<(Self, Option<String>), ConfigError> {
555571
let ext = path
@@ -654,6 +670,14 @@ impl Config {
654670
self.listen_port, self.listen_host
655671
)));
656672
}
673+
if !is_loopback_listen_host(&self.listen_host) {
674+
return Err(ConfigError::Invalid(format!(
675+
"listen_host '{}' is not loopback. Non-loopback proxy binds \
676+
are disabled until inbound HTTP/SOCKS authentication is \
677+
configured; use 127.0.0.1 or ::1 in config.toml.",
678+
self.listen_host
679+
)));
680+
}
657681
for (i, g) in self.fronting_groups.iter().enumerate() {
658682
if g.name.trim().is_empty() {
659683
return Err(ConfigError::Invalid(format!(
@@ -1184,6 +1208,56 @@ mod tests {
11841208
let cfg: Config = serde_json::from_str(s).unwrap();
11851209
assert!(cfg.validate().is_err());
11861210
}
1211+
1212+
#[test]
1213+
fn defaults_listen_host_to_loopback() {
1214+
let s = r#"{
1215+
"mode": "direct"
1216+
}"#;
1217+
let cfg: Config = serde_json::from_str(s).unwrap();
1218+
assert_eq!(cfg.listen_host, "127.0.0.1");
1219+
cfg.validate().unwrap();
1220+
}
1221+
1222+
#[test]
1223+
fn validate_accepts_loopback_listen_hosts() {
1224+
for host in ["127.0.0.1", "::1", "[::1]", "localhost"] {
1225+
let s = format!(
1226+
r#"{{
1227+
"mode": "direct",
1228+
"listen_host": "{}"
1229+
}}"#,
1230+
host
1231+
);
1232+
let cfg: Config = serde_json::from_str(&s).unwrap();
1233+
cfg.validate()
1234+
.unwrap_or_else(|e| panic!("expected loopback host '{}' to validate: {}", host, e));
1235+
}
1236+
}
1237+
1238+
#[test]
1239+
fn validate_rejects_non_loopback_listen_hosts() {
1240+
for host in ["0.0.0.0", "::", "[::]", "192.168.1.10", "example.com"] {
1241+
let s = format!(
1242+
r#"{{
1243+
"mode": "direct",
1244+
"listen_host": "{}"
1245+
}}"#,
1246+
host
1247+
);
1248+
let cfg: Config = serde_json::from_str(&s).unwrap();
1249+
let err = cfg
1250+
.validate()
1251+
.expect_err(&format!("expected non-loopback host '{}' to fail", host));
1252+
let msg = format!("{}", err);
1253+
assert!(
1254+
msg.contains("not loopback") && msg.contains("inbound HTTP/SOCKS authentication"),
1255+
"error should explain unsafe bind gate for '{}': {}",
1256+
host,
1257+
msg
1258+
);
1259+
}
1260+
}
11871261
}
11881262

11891263
#[cfg(test)]
@@ -1316,6 +1390,7 @@ mode = "direct"
13161390
let toml_cfg: TomlConfig = toml::from_str(s).unwrap();
13171391
let cfg = Config::from(toml_cfg);
13181392
assert_eq!(cfg.google_ip, "216.239.38.120");
1393+
assert_eq!(cfg.listen_host, "127.0.0.1");
13191394
assert_eq!(cfg.listen_port, 8085);
13201395
assert!(cfg.verify_ssl);
13211396
assert!(cfg.block_doh);
@@ -1445,9 +1520,11 @@ script_id = "ABCDEF"
14451520
assert_eq!(cfg.mode, cfg2.mode);
14461521
assert_eq!(cfg.auth_key, cfg2.auth_key);
14471522
assert_eq!(cfg.script_ids_resolved(), cfg2.script_ids_resolved());
1523+
assert_eq!(cfg.listen_host, "127.0.0.1");
1524+
assert_eq!(cfg.listen_host, cfg2.listen_host);
14481525
assert_eq!(cfg.listen_port, cfg2.listen_port);
14491526

14501527
let _ = std::fs::remove_file(&json_path);
14511528
let _ = std::fs::remove_file(&toml_path);
14521529
}
1453-
}
1530+
}

0 commit comments

Comments
 (0)