security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,17 +158,77 @@ pub async fn get_stats(data_dir: &Path) -> Result<RelayStats> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalize a relay URL (ensure wss:// prefix).
|
||||
/// Normalize and validate a relay URL.
|
||||
/// Only allows wss:// scheme (not ws://) for security.
|
||||
/// Rejects URLs pointing to private/internal IPs.
|
||||
fn normalize_relay_url(url: &str) -> Result<String> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow::anyhow!("Relay URL cannot be empty"));
|
||||
}
|
||||
if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
|
||||
Ok(trimmed.to_string())
|
||||
} else {
|
||||
Ok(format!("wss://{}", trimmed))
|
||||
if trimmed.len() > 2048 {
|
||||
return Err(anyhow::anyhow!("Relay URL too long"));
|
||||
}
|
||||
|
||||
// Apply wss:// prefix if no scheme
|
||||
let with_scheme = if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("wss://{}", trimmed)
|
||||
};
|
||||
|
||||
// Only allow wss:// scheme for security
|
||||
if !with_scheme.starts_with("wss://") {
|
||||
return Err(anyhow::anyhow!("Relay URL must use wss:// scheme"));
|
||||
}
|
||||
|
||||
// Extract host portion for SSRF validation
|
||||
let host = with_scheme
|
||||
.strip_prefix("wss://")
|
||||
.unwrap_or("")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
if host.is_empty() {
|
||||
return Err(anyhow::anyhow!("Relay URL must have a valid host"));
|
||||
}
|
||||
|
||||
// Reject private/internal addresses
|
||||
if is_relay_host_private(host) {
|
||||
return Err(anyhow::anyhow!("Relay URL must not point to private/local addresses"));
|
||||
}
|
||||
|
||||
Ok(with_scheme)
|
||||
}
|
||||
|
||||
/// Check if a relay host points to a private or internal address.
|
||||
fn is_relay_host_private(host: &str) -> bool {
|
||||
let lower = host.to_lowercase();
|
||||
if lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() || v6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
||||
}
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -183,9 +243,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_with_ws() {
|
||||
let result = normalize_relay_url("ws://relay.example.com").unwrap();
|
||||
assert_eq!(result, "ws://relay.example.com");
|
||||
fn test_normalize_relay_url_rejects_ws() {
|
||||
let result = normalize_relay_url("ws://relay.example.com");
|
||||
assert!(result.is_err(), "ws:// scheme should be rejected — only wss:// is allowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -209,6 +269,14 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_relay_url_rejects_private_ips() {
|
||||
assert!(normalize_relay_url("wss://127.0.0.1").is_err());
|
||||
assert!(normalize_relay_url("wss://localhost").is_err());
|
||||
assert!(normalize_relay_url("wss://192.168.1.1").is_err());
|
||||
assert!(normalize_relay_url("wss://10.0.0.1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_defaults_has_expected_count() {
|
||||
let store = seed_defaults();
|
||||
|
||||
Reference in New Issue
Block a user