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:
Dorian
2026-03-19 12:44:31 +00:00
parent 28763c4f09
commit 84a56c80de
77 changed files with 2485 additions and 966 deletions

View File

@@ -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();