Skip to content

Commit 23b7e0f

Browse files
committed
feat(auth): implement inbound access control and non-loopback bind validation guard
This change introduces local inbound authentication and security gating to protect proxy interfaces from unauthorized usage and resource exhaustion (quota theft) when exposed on local networks or the public internet. 1. Secure Configuration Defaults & Redaction (src/config.rs): - Changed the default `listen_host` from `0.0.0.0` to `127.0.0.1` (loopback only) to ensure secure-by-default behavior upon initial deployment. - Changed the default `block_stun` value to `true` to block WebRTC IP address discovery probes. - Implemented a custom `std::fmt::Debug` implementation for the `Config` struct that automatically hides the `inbound_password` field with `"[REDACTED]"`. - Exposed `Config::validate` as a public method so that UI saving operations can inspect config safety before serialization. 2. Non-Loopback Bind Validation Guard (src/config.rs): - Extended `Config::validate` to inspect the listen address. - If the address binds to any non-loopback interface (such as wildcards `0.0.0.0` and `::`, or external LAN/WAN interfaces) and `inbound_username` or `inbound_password` is empty, validation is rejected with a descriptive security error warning of quota theft and unauthorized usage risks. 3. SOCKS5 Inbound Authentication (src/proxy_server.rs): - In SOCKS5 client negotiation, if inbound credentials are set, the proxy advertises Username/Password authentication (Method 0x02). If the client does not support it, it rejects the handshake with 0xFF. - Implemented RFC 1929 authentication subnegotiation: parses sub-protocol version 1, reads the length-prefixed username and password, performs validation, and returns status 0x00 on success or 0x01 on failure (terminating the connection). - If no inbound credentials are set, it defaults to the standard no-authentication (0x00) method. 4. HTTP Inbound Proxy Authentication (src/proxy_server.rs): - In HTTP/HTTPS client handling, if inbound credentials are set, the proxy inspects the `Proxy-Authorization` header (checked case-insensitively). - Parses the authentication token in `Basic <Base64>` format, decodes it using the STANDARD base64 engine, and verifies the credentials. - If credentials are missing or incorrect, it returns a local `407 Proxy Authentication Required` status with `Proxy-Authenticate: Basic realm="mhrv-rs"` and terminates the socket connection. 5. UI Access Controls & Badge System (src/bin/ui.rs): - Added an Obsidian-themed UI panel for "Inbound Access Control" containing username/password input fields, visibility toggles, and a secure random credentials generator. - Rendered dynamic security status badges based on the bind configuration: a green "Local Only" badge when bound to loopback interfaces, and an orange/yellow "LAN Exposed" warning badge with security warning copy when bound to non-loopback interfaces. - Plumbed `Config::validate` into UI save routines to present configuration safety warnings to the user via toast notifications. Verification: - Added `test_non_loopback_bind_requires_credentials` in `src/config.rs` to verify validation of local loopback hosts (IPv4, IPv6, bracketed IPv6) and wildcards. - Added `test_handle_http_client_auth` in `src/proxy_server.rs` to verify local 407 response behavior and successful credentials passage. - Added `test_handle_socks5_client_auth` in `src/proxy_server.rs` to verify SOCKS5 RFC 1929 method negotiation, subnegotiation failure, and subnegotiation success. - Verified that all unit and integration tests compile and run green.
1 parent bdbc4c0 commit 23b7e0f

3 files changed

Lines changed: 543 additions & 11 deletions

File tree

src/bin/ui.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ struct FormState {
321321
/// claude.ai / grok.com / x.com). Config-only — no UI editor yet.
322322
/// See `assets/exit_node/` for the generic exit-node handler.
323323
exit_node: mhrv_rs::config::ExitNodeConfig,
324+
inbound_username: String,
325+
inbound_password: String,
326+
show_inbound_password: bool,
324327
}
325328

326329
#[derive(Clone, Debug)]
@@ -426,6 +429,9 @@ fn load_form() -> (FormState, Option<String>) {
426429
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
427430
request_timeout_secs: c.request_timeout_secs,
428431
exit_node: c.exit_node.clone(),
432+
inbound_username: c.inbound_username,
433+
inbound_password: c.inbound_password,
434+
show_inbound_password: false,
429435
}
430436
} else {
431437
FormState {
@@ -468,6 +474,9 @@ fn load_form() -> (FormState, Option<String>) {
468474
auto_blacklist_cooldown_secs: 120,
469475
request_timeout_secs: 30,
470476
exit_node: mhrv_rs::config::ExitNodeConfig::default(),
477+
inbound_username: String::new(),
478+
inbound_password: String::new(),
479+
show_inbound_password: false,
471480
}
472481
};
473482
(form, load_err)
@@ -658,7 +667,11 @@ impl FormState {
658667
// / grok.com / x.com). Round-trip through FormState — config-only
659668
// editing for now, UI editor planned for v1.9.x desktop UI batch.
660669
exit_node: self.exit_node.clone(),
661-
})
670+
inbound_username: self.inbound_username.trim().to_string(),
671+
inbound_password: self.inbound_password.trim().to_string(),
672+
};
673+
cfg.validate().map_err(|e| e.to_string())?;
674+
Ok(cfg)
662675
}
663676
}
664677

@@ -749,6 +762,10 @@ struct ConfigWire<'a> {
749762
/// Save preserves user-edited values.
750763
#[serde(skip_serializing_if = "is_default_exit_node")]
751764
exit_node: &'a mhrv_rs::config::ExitNodeConfig,
765+
#[serde(skip_serializing_if = "is_empty_str")]
766+
inbound_username: &'a str,
767+
#[serde(skip_serializing_if = "is_empty_str")]
768+
inbound_password: &'a str,
752769
}
753770

754771
fn is_default_strikes(v: &u32) -> bool { *v == 3 }
@@ -763,6 +780,10 @@ fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool {
763780
&& (en.mode.is_empty() || en.mode == "selective")
764781
}
765782

783+
fn is_empty_str(s: &&str) -> bool {
784+
s.is_empty()
785+
}
786+
766787
fn is_false(b: &bool) -> bool {
767788
!*b
768789
}
@@ -824,6 +845,8 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
824845
request_timeout_secs: c.request_timeout_secs,
825846
force_http1: c.force_http1,
826847
exit_node: &c.exit_node,
848+
inbound_username: c.inbound_username.as_str(),
849+
inbound_password: c.inbound_password.as_str(),
827850
}
828851
}
829852
}
@@ -1226,6 +1249,142 @@ impl eframe::App for App {
12261249
});
12271250
});
12281251

1252+
// ── Section: Inbound Access Control ───────────────────────────
1253+
section(ui, "Inbound Access Control", |ui| {
1254+
// Binding Status & Badges
1255+
ui.horizontal(|ui| {
1256+
ui.add_sized(
1257+
[120.0, 20.0],
1258+
egui::Label::new(egui::RichText::new("Binding Security").color(egui::Color32::from_gray(200))),
1259+
);
1260+
1261+
let listen_host_snapshot = self.form.listen_host.trim();
1262+
let is_loopback = mhrv_rs::lan_utils::is_loopback_only(listen_host_snapshot)
1263+
|| listen_host_snapshot.parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
1264+
|| (listen_host_snapshot.starts_with('[') && listen_host_snapshot.ends_with(']')
1265+
&& listen_host_snapshot[1..listen_host_snapshot.len()-1].parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false));
1266+
1267+
if is_loopback {
1268+
// Green Local Only badge
1269+
egui::Frame::none()
1270+
.fill(OK_GREEN)
1271+
.rounding(4.0)
1272+
.inner_margin(egui::Margin {
1273+
left: 6.0,
1274+
right: 6.0,
1275+
top: 2.0,
1276+
bottom: 2.0,
1277+
})
1278+
.show(ui, |ui| {
1279+
ui.label(egui::RichText::new("Local Only").color(egui::Color32::BLACK).strong().size(10.0));
1280+
});
1281+
} else {
1282+
// Orange LAN Exposed badge
1283+
egui::Frame::none()
1284+
.fill(egui::Color32::from_rgb(230, 160, 50))
1285+
.rounding(4.0)
1286+
.inner_margin(egui::Margin {
1287+
left: 6.0,
1288+
right: 6.0,
1289+
top: 2.0,
1290+
bottom: 2.0,
1291+
})
1292+
.show(ui, |ui| {
1293+
ui.label(egui::RichText::new("LAN Exposed").color(egui::Color32::BLACK).strong().size(10.0));
1294+
});
1295+
}
1296+
});
1297+
1298+
// Display warning if LAN Exposed
1299+
let listen_host_snapshot = self.form.listen_host.trim();
1300+
let is_loopback = mhrv_rs::lan_utils::is_loopback_only(listen_host_snapshot)
1301+
|| listen_host_snapshot.parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
1302+
|| (listen_host_snapshot.starts_with('[') && listen_host_snapshot.ends_with(']')
1303+
&& listen_host_snapshot[1..listen_host_snapshot.len()-1].parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false));
1304+
1305+
if !is_loopback {
1306+
ui.add_space(4.0);
1307+
ui.horizontal(|ui| {
1308+
ui.add_space(120.0 + 8.0);
1309+
ui.vertical(|ui| {
1310+
ui.colored_label(
1311+
egui::Color32::from_rgb(230, 160, 50),
1312+
"⚠ WARNING: Binding to a non-loopback address exposes this proxy on your network. \
1313+
Anyone on your LAN can connect, consume your Apps Script execution quota, and access local network resources. \
1314+
Secure inbound credentials are required to start the server.",
1315+
);
1316+
});
1317+
});
1318+
} else {
1319+
ui.add_space(4.0);
1320+
ui.horizontal(|ui| {
1321+
ui.add_space(120.0 + 8.0);
1322+
ui.vertical(|ui| {
1323+
ui.colored_label(
1324+
egui::Color32::from_gray(140),
1325+
"Proxy is bound to loopback. Secure from external network access.",
1326+
);
1327+
});
1328+
});
1329+
}
1330+
1331+
ui.add_space(6.0);
1332+
1333+
// Username input
1334+
form_row(ui, "Inbound User", Some("Username required for client authentication when LAN sharing is enabled."), |ui, label_id| {
1335+
ui.add(egui::TextEdit::singleline(&mut self.form.inbound_username)
1336+
.hint_text("Optional on loopback; required on LAN")
1337+
.desired_width(f32::INFINITY))
1338+
.labelled_by(label_id);
1339+
});
1340+
1341+
ui.add_space(4.0);
1342+
1343+
// Password input
1344+
form_row(ui, "Inbound Pass", Some("Password required for client authentication when LAN sharing is enabled."), |ui, label_id| {
1345+
ui.horizontal(|ui| {
1346+
ui.add(egui::TextEdit::singleline(&mut self.form.inbound_password)
1347+
.password(!self.form.show_inbound_password)
1348+
.desired_width(ui.available_width() - 80.0))
1349+
.labelled_by(label_id);
1350+
1351+
if ui.button(if self.form.show_inbound_password { "Hide" } else { "Show" }).clicked() {
1352+
self.form.show_inbound_password = !self.form.show_inbound_password;
1353+
}
1354+
});
1355+
});
1356+
1357+
ui.add_space(6.0);
1358+
1359+
// Random credentials generator button
1360+
ui.horizontal(|ui| {
1361+
ui.add_space(120.0 + 8.0);
1362+
let gen_btn = egui::Button::new(
1363+
egui::RichText::new("🎲 Generate Random Credentials")
1364+
.color(egui::Color32::WHITE),
1365+
)
1366+
.fill(egui::Color32::from_rgb(50, 54, 60))
1367+
.rounding(4.0);
1368+
1369+
if ui.add(gen_btn).on_hover_text("Generate a strong secure username and password automatically.").clicked() {
1370+
let (uname, passwd) = {
1371+
use rand::Rng;
1372+
let mut rng = rand::thread_rng();
1373+
let u: String = (0..8)
1374+
.map(|_| rng.sample(rand::distributions::Alphanumeric) as char)
1375+
.collect();
1376+
let p: String = (0..16)
1377+
.map(|_| rng.sample(rand::distributions::Alphanumeric) as char)
1378+
.collect();
1379+
(u.to_ascii_lowercase(), p)
1380+
};
1381+
self.form.inbound_username = uname;
1382+
self.form.inbound_password = passwd;
1383+
self.toast = Some(("Generated secure credentials. Don't forget to save config!".into(), Instant::now()));
1384+
}
1385+
});
1386+
});
1387+
12291388
// ── Section: Advanced (collapsed by default) ──────────────────
12301389
ui.add_space(6.0);
12311390
egui::CollapsingHeader::new(

src/config.rs

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl ScriptId {
5757
}
5858
}
5959

60-
#[derive(Debug, Clone, Deserialize)]
60+
#[derive(Clone, Deserialize)]
6161
pub struct Config {
6262
pub mode: String,
6363
#[serde(default = "default_google_ip")]
@@ -85,6 +85,10 @@ pub struct Config {
8585
#[serde(default)]
8686
pub hosts: HashMap<String, String>,
8787
#[serde(default)]
88+
pub inbound_username: String,
89+
#[serde(default)]
90+
pub inbound_password: String,
91+
#[serde(default)]
8892
pub enable_batching: bool,
8993
/// Optional upstream SOCKS5 proxy for non-HTTP / raw-TCP traffic
9094
/// (e.g. `"127.0.0.1:50529"` pointing at a local xray / v2ray instance).
@@ -405,6 +409,55 @@ pub struct Config {
405409
pub exit_node: ExitNodeConfig,
406410
}
407411

412+
impl std::fmt::Debug for Config {
413+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414+
f.debug_struct("Config")
415+
.field("mode", &self.mode)
416+
.field("google_ip", &self.google_ip)
417+
.field("front_domain", &self.front_domain)
418+
.field("script_id", &self.script_id)
419+
.field("script_ids", &self.script_ids)
420+
.field("auth_key", &self.auth_key)
421+
.field("listen_host", &self.listen_host)
422+
.field("listen_port", &self.listen_port)
423+
.field("socks5_port", &self.socks5_port)
424+
.field("log_level", &self.log_level)
425+
.field("verify_ssl", &self.verify_ssl)
426+
.field("auto_system_proxy", &self.auto_system_proxy)
427+
.field("hosts", &self.hosts)
428+
.field("enable_batching", &self.enable_batching)
429+
.field("upstream_socks5", &self.upstream_socks5)
430+
.field("parallel_relay", &self.parallel_relay)
431+
.field("coalesce_step_ms", &self.coalesce_step_ms)
432+
.field("coalesce_max_ms", &self.coalesce_max_ms)
433+
.field("sni_hosts", &self.sni_hosts)
434+
.field("fetch_ips_from_api", &self.fetch_ips_from_api)
435+
.field("max_ips_to_scan", &self.max_ips_to_scan)
436+
.field("scan_batch_size", &self.scan_batch_size)
437+
.field("google_ip_validation", &self.google_ip_validation)
438+
.field("normalize_x_graphql", &self.normalize_x_graphql)
439+
.field("youtube_via_relay", &self.youtube_via_relay)
440+
.field("passthrough_hosts", &self.passthrough_hosts)
441+
.field("block_hosts", &self.block_hosts)
442+
.field("block_stun", &self.block_stun)
443+
.field("block_quic", &self.block_quic)
444+
.field("disable_padding", &self.disable_padding)
445+
.field("force_http1", &self.force_http1)
446+
.field("tunnel_doh", &self.tunnel_doh)
447+
.field("bypass_doh_hosts", &self.bypass_doh_hosts)
448+
.field("block_doh", &self.block_doh)
449+
.field("fronting_groups", &self.fronting_groups)
450+
.field("auto_blacklist_strikes", &self.auto_blacklist_strikes)
451+
.field("auto_blacklist_window_secs", &self.auto_blacklist_window_secs)
452+
.field("auto_blacklist_cooldown_secs", &self.auto_blacklist_cooldown_secs)
453+
.field("request_timeout_secs", &self.request_timeout_secs)
454+
.field("exit_node", &self.exit_node)
455+
.field("inbound_username", &self.inbound_username)
456+
.field("inbound_password", &if self.inbound_password.is_empty() { "" } else { "[REDACTED]" })
457+
.finish()
458+
}
459+
}
460+
408461
/// Configuration for the optional second-hop exit node.
409462
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
410463
pub struct ExitNodeConfig {
@@ -511,7 +564,7 @@ fn default_tunnel_doh() -> bool { true }
511564
/// Default for `block_quic`: `true`. QUIC over the TCP-based tunnel
512565
/// causes TCP-over-TCP meltdown (<1 Mbps). Browsers fall back to
513566
/// HTTPS/TCP within seconds of the silent UDP drop. Issue #793.
514-
fn default_block_stun() -> bool { false }
567+
fn default_block_stun() -> bool { true }
515568
fn default_block_quic() -> bool { true }
516569

517570
/// Default for `block_doh`: `true` (browser DoH is rejected so the
@@ -543,7 +596,7 @@ fn default_front_domain() -> String {
543596
"www.google.com".into()
544597
}
545598
fn default_listen_host() -> String {
546-
"0.0.0.0".into()
599+
"127.0.0.1".into()
547600
}
548601
fn default_listen_port() -> u16 {
549602
8085
@@ -564,7 +617,24 @@ impl Config {
564617
Ok(cfg)
565618
}
566619

567-
fn validate(&self) -> Result<(), ConfigError> {
620+
pub fn validate(&self) -> Result<(), ConfigError> {
621+
// Safety guard: non-loopback bind requires active inbound credentials
622+
let is_loopback = crate::lan_utils::is_loopback_only(&self.listen_host)
623+
|| self.listen_host.trim().parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
624+
|| (self.listen_host.trim().starts_with('[') && self.listen_host.trim().ends_with(']')
625+
&& self.listen_host.trim()[1..self.listen_host.trim().len()-1].parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false));
626+
627+
if !is_loopback {
628+
if self.inbound_username.trim().is_empty() || self.inbound_password.trim().is_empty() {
629+
return Err(ConfigError::Invalid(
630+
"Non-loopback bind exposes the proxy to the local network (LAN) or public internet. \
631+
For security, this setup is blocked unless you configure 'inbound_username' and 'inbound_password' \
632+
in your settings to prevent unauthorized usage and quota theft. Alternatively, bind to loopback (127.0.0.1)."
633+
.into(),
634+
));
635+
}
636+
}
637+
568638
let mode = self.mode_kind()?;
569639
if mode == Mode::AppsScript || mode == Mode::Full {
570640
if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" {
@@ -801,6 +871,51 @@ mod tests {
801871
assert!(cfg.validate().is_err());
802872
}
803873

874+
#[test]
875+
fn test_non_loopback_bind_requires_credentials() {
876+
// 1. Loopback bind works fine without credentials
877+
let s1 = r#"{
878+
"mode": "direct",
879+
"listen_host": "127.0.0.1"
880+
}"#;
881+
let cfg1: Config = serde_json::from_str(s1).unwrap();
882+
cfg1.validate().expect("loopback 127.0.0.1 should validate without inbound credentials");
883+
884+
// IPv6 loopback
885+
let s2 = r#"{
886+
"mode": "direct",
887+
"listen_host": "::1"
888+
}"#;
889+
let cfg2: Config = serde_json::from_str(s2).unwrap();
890+
cfg2.validate().expect("loopback ::1 should validate without inbound credentials");
891+
892+
let s2_bracket = r#"{
893+
"mode": "direct",
894+
"listen_host": "[::1]"
895+
}"#;
896+
let cfg2_b: Config = serde_json::from_str(s2_bracket).unwrap();
897+
cfg2_b.validate().expect("loopback [::1] should validate without inbound credentials");
898+
899+
// 2. Non-loopback wildcard 0.0.0.0 fails validation without credentials
900+
let s3 = r#"{
901+
"mode": "direct",
902+
"listen_host": "0.0.0.0"
903+
}"#;
904+
let cfg3: Config = serde_json::from_str(s3).unwrap();
905+
assert!(cfg3.validate().is_err(), "wildcard 0.0.0.0 bind should fail without inbound credentials");
906+
907+
// 3. Non-loopback wildcard 0.0.0.0 succeeds validation with credentials
908+
let s4 = r#"{
909+
"mode": "direct",
910+
"listen_host": "0.0.0.0",
911+
"inbound_username": "admin",
912+
"inbound_password": "password123"
913+
}"#;
914+
let cfg4: Config = serde_json::from_str(s4).unwrap();
915+
cfg4.validate().expect("wildcard 0.0.0.0 bind should succeed with inbound credentials");
916+
}
917+
918+
804919
#[test]
805920
fn fronting_groups_parse_and_validate() {
806921
let s = r#"{

0 commit comments

Comments
 (0)