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:
co-authored by
Claude Opus 4.6
parent
28763c4f09
commit
84a56c80de
@@ -77,11 +77,14 @@ impl ApiHandler {
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
vec![
|
||||
let mut origins = vec![
|
||||
format!("http://{}", self.config.host_ip),
|
||||
format!("https://{}", self.config.host_ip),
|
||||
"http://localhost:8100".to_string(), // Vite dev server
|
||||
]
|
||||
];
|
||||
if self.config.dev_mode {
|
||||
origins.push("http://localhost:8100".to_string()); // Vite dev server
|
||||
}
|
||||
origins
|
||||
}
|
||||
|
||||
/// Validate the Origin header against allowed origins.
|
||||
|
||||
@@ -76,7 +76,21 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(complete))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
||||
pub(super) async fn handle_auth_reset_onboarding(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
self.auth_manager.reset_onboarding().await?;
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
use super::RpcHandler;
|
||||
use crate::backup::full;
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::IpAddr;
|
||||
use tracing::info;
|
||||
|
||||
/// Validate an S3 endpoint URL: require https, reject private/loopback IPs and localhost.
|
||||
fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
||||
// Require HTTPS scheme
|
||||
if !endpoint.starts_with("https://") {
|
||||
anyhow::bail!("S3 endpoint must use https://");
|
||||
}
|
||||
|
||||
// Extract host from URL (strip scheme, path, port)
|
||||
let after_scheme = &endpoint["https://".len()..];
|
||||
let host_port = after_scheme.split('/').next().unwrap_or("");
|
||||
// Strip port if present (handle IPv6 bracket notation)
|
||||
let host = if host_port.starts_with('[') {
|
||||
// IPv6: [::1]:443
|
||||
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
|
||||
} else {
|
||||
host_port.split(':').next().unwrap_or("")
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
anyhow::bail!("S3 endpoint missing host");
|
||||
}
|
||||
|
||||
// Reject localhost
|
||||
if host == "localhost" || host.ends_with(".localhost") {
|
||||
anyhow::bail!("S3 endpoint must not point to localhost");
|
||||
}
|
||||
|
||||
// Parse as IP and reject private/reserved ranges
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
let is_private = match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback() // 127.0.0.0/8
|
||||
|| v4.octets()[0] == 10 // 10.0.0.0/8
|
||||
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||
|| v4.is_unspecified() // 0.0.0.0
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback() // ::1
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||
|| v6.is_unspecified() // ::
|
||||
}
|
||||
};
|
||||
if is_private {
|
||||
anyhow::bail!("S3 endpoint must not point to a private or reserved IP address");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Create a full encrypted backup. Params: { passphrase, description? }
|
||||
pub(super) async fn handle_backup_create(
|
||||
@@ -55,6 +108,11 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
let result = full::verify_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -78,6 +136,11 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||
|
||||
// Validate backup ID to prevent path traversal
|
||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
full::restore_full_backup(&self.config.data_dir, id, passphrase).await?;
|
||||
|
||||
Ok(serde_json::json!({ "restored": true, "id": id }))
|
||||
@@ -183,6 +246,9 @@ impl RpcHandler {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||
if !bak_path.exists() {
|
||||
anyhow::bail!("Backup not found: {}", id);
|
||||
@@ -255,6 +321,9 @@ impl RpcHandler {
|
||||
anyhow::bail!("Invalid backup ID");
|
||||
}
|
||||
|
||||
// Validate endpoint to prevent SSRF against internal services
|
||||
validate_s3_endpoint(endpoint)?;
|
||||
|
||||
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
|
||||
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@ use crate::network::dwn_store::DwnStore;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::debug;
|
||||
|
||||
/// Validate a v3 Tor onion address.
|
||||
/// Must be exactly 62 chars: 56 base32 characters (a-z, 2-7) followed by ".onion".
|
||||
fn is_valid_v3_onion(addr: &str) -> bool {
|
||||
if addr.len() != 62 || !addr.ends_with(".onion") {
|
||||
return false;
|
||||
}
|
||||
let prefix = &addr[..56];
|
||||
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
||||
}
|
||||
|
||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -25,10 +35,16 @@ impl RpcHandler {
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
|
||||
// Prevent path traversal
|
||||
if filename.contains("..") || filename.contains('\0') {
|
||||
// Validate filename: prevent path traversal, hidden files, and excessive length
|
||||
if filename.contains("..") || filename.contains('\0') || filename.contains('/') || filename.contains('\\') {
|
||||
anyhow::bail!("Invalid filename: path traversal not allowed");
|
||||
}
|
||||
if filename.starts_with('.') {
|
||||
anyhow::bail!("Invalid filename: hidden files not allowed");
|
||||
}
|
||||
if filename.is_empty() || filename.len() > 255 {
|
||||
anyhow::bail!("Invalid filename: must be 1-255 characters");
|
||||
}
|
||||
let mime_type = params
|
||||
.get("mime_type")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -191,8 +207,9 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||
|
||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||
@@ -252,9 +269,9 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||
|
||||
// Validate onion address format
|
||||
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||
// Validate v3 onion address: 56 base32 chars + ".onion" = 62 chars total
|
||||
if !is_valid_v3_onion(onion) {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
||||
|
||||
@@ -371,7 +371,8 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified");
|
||||
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
||||
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ impl RpcHandler {
|
||||
"auth.changePassword" => self.handle_auth_change_password(params, &session_token).await,
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
@@ -789,7 +789,7 @@ impl RpcHandler {
|
||||
self.metrics_store.record_rpc_latency(elapsed_ms).await;
|
||||
|
||||
// Build response (cache successful results for cacheable methods)
|
||||
let rpc_resp = match result {
|
||||
let mut rpc_resp = match result {
|
||||
Ok(data) => {
|
||||
if is_cacheable {
|
||||
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
|
||||
@@ -893,8 +893,62 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// On successful TOTP verification, the session is already upgraded to full
|
||||
// (handled inside handle_login_totp/handle_login_backup)
|
||||
// On successful TOTP verification, set the rotated session cookie
|
||||
if (rpc_req.method == "auth.login.totp" || rpc_req.method == "auth.login.backup")
|
||||
&& rpc_resp.error.is_none()
|
||||
{
|
||||
// Extract token (clone to release immutable borrow before mutable borrow below)
|
||||
let new_token_opt = rpc_resp
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("new_session_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(new_token) = new_token_opt {
|
||||
let csrf_token = derive_csrf_token(&new_token);
|
||||
let remember_token = self.session_store.create_remember_token();
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"session={}; HttpOnly; SameSite=Strict; Path=/{}",
|
||||
new_token,
|
||||
self.cookie_suffix()
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"csrf_token={}; SameSite=Strict; Path=/{}",
|
||||
csrf_token,
|
||||
self.cookie_suffix()
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"remember={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
|
||||
remember_token,
|
||||
REMEMBER_TTL,
|
||||
self.cookie_suffix()
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// Strip the token from the response body — don't leak it to JS
|
||||
if let Some(result) = rpc_resp.result.as_mut() {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.remove("new_session_token");
|
||||
}
|
||||
}
|
||||
let body_bytes = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||
*response.body_mut() = hyper::Body::from(body_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// On password change, rotate the session token for the caller
|
||||
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
|
||||
|
||||
@@ -686,6 +686,12 @@ printtoconsole=1\n", rpc_pass);
|
||||
sorted
|
||||
};
|
||||
|
||||
// Clear user-stopped flag — user explicitly started this app
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||
for name in &to_start {
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", &name])
|
||||
@@ -707,9 +713,13 @@ printtoconsole=1\n", rpc_pass);
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name).await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &container_name])
|
||||
.output()
|
||||
@@ -717,6 +727,9 @@ printtoconsole=1\n", rpc_pass);
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in &containers {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &name])
|
||||
@@ -1025,6 +1038,7 @@ printtoconsole=1\n", rpc_pass);
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
|
||||
@@ -609,6 +609,18 @@ impl RpcHandler {
|
||||
anyhow::bail!("Factory reset requires {{ \"confirm\": true }}");
|
||||
}
|
||||
|
||||
// Require password re-authentication for destructive operations
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing password — re-authentication required"))?;
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
@@ -118,6 +118,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
// Validate name to prevent path traversal
|
||||
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
|
||||
let onion = read_onion_address(name);
|
||||
Ok(serde_json::json!({ "name": name, "onion_address": onion }))
|
||||
}
|
||||
@@ -134,6 +139,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
|
||||
// Validate name to prevent path traversal
|
||||
if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
|
||||
let base = tor_data_dir();
|
||||
let service_dir = format!("{}/hidden_service_{}", base, name);
|
||||
|
||||
@@ -277,6 +287,12 @@ impl RpcHandler {
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
|
||||
// Validate app_id to prevent path traversal
|
||||
if app_id.is_empty() || app_id.len() > 64 || !app_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(anyhow::anyhow!("Invalid app_id (alphanumeric, hyphens, underscores only)"));
|
||||
}
|
||||
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
|
||||
@@ -192,10 +192,11 @@ impl RpcHandler {
|
||||
let _ = self.auth_manager.update_totp(data).await;
|
||||
}
|
||||
|
||||
// Upgrade pending session to full
|
||||
self.session_store.upgrade_to_full(token).await;
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self.session_store.upgrade_to_full(token).await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid code. Please try again.");
|
||||
@@ -241,13 +242,14 @@ impl RpcHandler {
|
||||
totp_data.backup_codes.remove(idx);
|
||||
self.auth_manager.update_totp(totp_data).await?;
|
||||
|
||||
// Upgrade pending session to full
|
||||
self.session_store.upgrade_to_full(token).await;
|
||||
// Upgrade pending session to full (rotates token)
|
||||
let new_token = self.session_store.upgrade_to_full(token).await
|
||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||
|
||||
tracing::info!("Login via backup code (codes remaining: {})",
|
||||
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!("Invalid backup code");
|
||||
|
||||
@@ -3,6 +3,90 @@ 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> {
|
||||
@@ -28,37 +112,35 @@ impl RpcHandler {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
|
||||
// Validate webhook URL scheme and reject obviously dangerous targets
|
||||
// Validate webhook URL scheme and reject dangerous targets
|
||||
if !url.is_empty() {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
anyhow::bail!("Webhook URL must use HTTP(S)");
|
||||
}
|
||||
if !self.config.dev_mode && !url.starts_with("https://") {
|
||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||
}
|
||||
// Extract host portion and reject private/internal addresses
|
||||
let host_part = url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let is_private = host_part == "localhost"
|
||||
|| host_part == "127.0.0.1"
|
||||
|| host_part == "::1"
|
||||
|| host_part.starts_with("10.")
|
||||
|| host_part.starts_with("172.")
|
||||
|| host_part.starts_with("192.168.")
|
||||
|| host_part.starts_with("169.254.");
|
||||
if is_private && !self.config.dev_mode {
|
||||
anyhow::bail!("Webhook URL must not point to private/local addresses");
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user