Dorian 1a74a930f7 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>
2026-03-19 12:44:31 +00:00

198 lines
7.8 KiB
Rust

use super::RpcHandler;
use crate::webhooks;
use anyhow::Result;
use tracing::info;
/// Check if a hostname/IP points to a private or internal address.
/// Handles: IPv4, IPv6 (including mapped IPv4 like ::ffff:127.0.0.1),
/// decimal/octal IP representations, and well-known internal hostnames.
fn is_webhook_host_private(host: &str) -> bool {
// Strip IPv6 brackets if present
let h = host.trim_start_matches('[').trim_end_matches(']');
// Check well-known internal hostnames
let lower = h.to_lowercase();
if lower == "localhost"
|| lower == "localhost.localdomain"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
|| lower == "metadata.google.internal"
|| lower == "169.254.169.254"
{
return true;
}
// Try to parse as IP address
if let Ok(ip) = h.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()
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
}
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() {
return true;
}
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified();
}
// Unique local (fd00::/8, fc00::/7)
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
}
};
}
// Detect decimal IP notation (e.g., "2130706433" = 127.0.0.1)
if let Ok(decimal) = h.parse::<u32>() {
let octets = decimal.to_be_bytes();
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
// Detect octal IP notation (e.g., "0177.0.0.1" = 127.0.0.1)
if h.contains('.') {
let parts: Vec<&str> = h.split('.').collect();
if parts.len() == 4 {
let mut octets = [0u8; 4];
let mut all_ok = true;
for (i, part) in parts.iter().enumerate() {
let val = if part.starts_with("0x") || part.starts_with("0X") {
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
} else if part.starts_with('0') && part.len() > 1 {
u64::from_str_radix(part, 8).ok()
} else {
part.parse::<u64>().ok()
};
match val {
Some(v) if v <= 255 => octets[i] = v as u8,
_ => { all_ok = false; break; }
}
}
if all_ok {
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
}
}
}
false
}
impl RpcHandler {
/// webhook.get-config — Get current webhook configuration.
pub(super) async fn handle_webhook_get_config(&self) -> Result<serde_json::Value> {
let config = webhooks::load_config(&self.config.data_dir).await?;
Ok(serde_json::json!({
"enabled": config.enabled,
"url": config.url,
"events": config.events,
"has_secret": config.secret.is_some(),
}))
}
/// webhook.configure — Update webhook configuration.
pub(super) async fn handle_webhook_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mut config = webhooks::load_config(&self.config.data_dir).await?;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.enabled = enabled;
}
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
// Validate webhook URL scheme and reject dangerous targets
if !url.is_empty() {
if url.len() > 2048 {
anyhow::bail!("Webhook URL too long");
}
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
let parsed = reqwest::Url::parse(url)
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
// Require https:// in production
if !self.config.dev_mode && parsed.scheme() != "https" {
anyhow::bail!("Webhook URL must use HTTPS in production");
}
if parsed.scheme() != "https" && parsed.scheme() != "http" {
anyhow::bail!("Webhook URL must use HTTP(S)");
}
// Reject URLs with userinfo (user:pass@host) — can be used for credential smuggling
if parsed.username() != "" || parsed.password().is_some() {
anyhow::bail!("Webhook URL must not contain credentials");
}
// Extract and validate the host
let host = parsed.host_str().unwrap_or("");
if host.is_empty() {
anyhow::bail!("Webhook URL must have a valid host");
}
// Reject private/internal addresses (handle IPv4, IPv6, decimal/octal IPs, hostnames)
let is_private = is_webhook_host_private(host);
if is_private && !self.config.dev_mode {
anyhow::bail!("Webhook URL must not point to private/local addresses");
}
}
config.url = url.to_string();
}
if let Some(secret) = params.get("secret").and_then(|v| v.as_str()) {
config.secret = if secret.is_empty() {
None
} else {
Some(secret.to_string())
};
}
if let Some(events) = params.get("events") {
if let Ok(parsed) = serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
{
config.events = parsed;
}
}
webhooks::save_config(&self.config.data_dir, &config).await?;
info!("Webhook config updated: enabled={}, url={}", config.enabled, config.url);
Ok(serde_json::json!({
"configured": true,
"enabled": config.enabled,
"url": config.url,
}))
}
/// webhook.test — Send a test webhook notification.
pub(super) async fn handle_webhook_test(&self) -> Result<serde_json::Value> {
let config = webhooks::load_config(&self.config.data_dir).await?;
if !config.enabled || config.url.is_empty() {
anyhow::bail!("Webhook is not configured. Set a URL and enable it first.");
}
let payload = webhooks::WebhookPayload {
event: webhooks::WebhookEvent::ContainerCrash,
title: "Test Notification".to_string(),
message: "This is a test webhook from your Archipelago node.".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
node_id: {
let (data, _) = self.state_manager.get_snapshot().await;
data.server_info.id
},
details: Some(serde_json::json!({"test": true})),
};
webhooks::send_webhook(&self.config.data_dir, payload).await;
Ok(serde_json::json!({
"sent": true,
"url": config.url,
}))
}
}