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();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ impl UserRole {
|
||||
|| method == "system.temperature"
|
||||
|| method == "system.disk-status"
|
||||
|| method == "system.detect-usb-devices"
|
||||
|| method.starts_with("node.")
|
||||
|| method == "node.did"
|
||||
|| method == "node.tor-address"
|
||||
|| method == "node.nostr-pubkey"
|
||||
|| method.starts_with("federation.list")
|
||||
|| method.starts_with("dwn.status")
|
||||
|| method.starts_with("dwn.list")
|
||||
|
||||
@@ -435,10 +435,46 @@ fn create_tar_gz(data_dir: &Path) -> Result<Vec<u8>> {
|
||||
fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
||||
let gz = GzDecoder::new(tar_gz_data);
|
||||
let mut archive = Archive::new(gz);
|
||||
let canonical_base = data_dir
|
||||
.canonicalize()
|
||||
.context("Failed to canonicalize data_dir")?;
|
||||
|
||||
archive
|
||||
.unpack(data_dir)
|
||||
.context("Failed to extract backup archive")?;
|
||||
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
||||
let mut entry = entry_result.context("Failed to read tar entry")?;
|
||||
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
|
||||
|
||||
// Reject entries with path traversal components
|
||||
for component in entry_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
anyhow::bail!(
|
||||
"Tar entry contains path traversal: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let target = data_dir.join(&entry_path);
|
||||
// Verify the resolved path stays within data_dir
|
||||
// For new files that don't exist yet, check the parent directory
|
||||
let check_path = if target.exists() {
|
||||
target.canonicalize()?
|
||||
} else if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
|
||||
} else {
|
||||
target.clone()
|
||||
};
|
||||
if !check_path.starts_with(&canonical_base) {
|
||||
anyhow::bail!(
|
||||
"Tar entry escapes target directory: {}",
|
||||
entry_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
entry
|
||||
.unpack(&target)
|
||||
.with_context(|| format!("Failed to extract: {}", entry_path.display()))?;
|
||||
}
|
||||
|
||||
debug!("Backup extracted to {:?}", data_dir);
|
||||
Ok(())
|
||||
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
fn test_default_config_values() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/archipelago"));
|
||||
assert_eq!(config.bind_host, "0.0.0.0");
|
||||
assert_eq!(config.bind_host, "127.0.0.1");
|
||||
assert_eq!(config.bind_port, 5678);
|
||||
assert_eq!(config.log_level, "info");
|
||||
assert_eq!(config.host_ip, "127.0.0.1");
|
||||
|
||||
@@ -145,6 +145,7 @@ impl DockerPackageScanner {
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
health: container.health.clone(),
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
@@ -592,9 +593,8 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
ContainerState::Stopped | ContainerState::Exited => {
|
||||
(PackageState::Stopped, ServiceStatus::Stopped)
|
||||
}
|
||||
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
|
||||
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
@@ -607,6 +607,7 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::Installed => "installed",
|
||||
PackageState::Stopping => "stopping",
|
||||
PackageState::Stopped => "stopped",
|
||||
PackageState::Exited => "exited",
|
||||
PackageState::Starting => "starting",
|
||||
PackageState::Running => "running",
|
||||
PackageState::Restarting => "restarting",
|
||||
|
||||
@@ -11,11 +11,64 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const PID_FILE: &str = "archipelago.pid";
|
||||
const CONTAINER_STATE_FILE: &str = "running-containers.json";
|
||||
const USER_STOPPED_FILE: &str = "user-stopped.json";
|
||||
|
||||
/// Shared flag: true once boot recovery is complete. Health monitor should wait for this.
|
||||
pub static RECOVERY_COMPLETE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Mark boot recovery as complete. Call after crash recovery + start_stopped_containers finish.
|
||||
pub fn mark_recovery_complete() {
|
||||
RECOVERY_COMPLETE.store(true, Ordering::SeqCst);
|
||||
info!("Boot recovery complete — health monitor may proceed");
|
||||
}
|
||||
|
||||
/// Check if boot recovery is done.
|
||||
pub fn is_recovery_complete() -> bool {
|
||||
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ── User-stopped tracking ───────────────────────────────────────────────
|
||||
// When a user explicitly stops a container via the UI, we record it here
|
||||
// so crash recovery and health monitor don't auto-restart it.
|
||||
|
||||
/// Load the set of user-stopped containers from disk.
|
||||
pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
match fs::read_to_string(&path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the set of user-stopped containers to disk.
|
||||
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(USER_STOPPED_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(stopped) {
|
||||
let _ = fs::write(&path, json).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a container as user-stopped (won't be auto-restarted).
|
||||
pub async fn mark_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
stopped.insert(name.to_string());
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
|
||||
/// Clear user-stopped flag (container was manually started by user).
|
||||
pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
||||
let mut stopped = load_user_stopped(data_dir).await;
|
||||
if stopped.remove(name) {
|
||||
save_user_stopped(data_dir, &stopped).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunningContainerRecord {
|
||||
@@ -241,7 +294,8 @@ fn is_process_running(pid: u32) -> bool {
|
||||
/// Start all stopped containers that were previously installed.
|
||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||
pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
/// Skips containers that the user intentionally stopped via the UI.
|
||||
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
@@ -257,7 +311,7 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
}
|
||||
};
|
||||
|
||||
let names: Vec<String> = match output {
|
||||
let all_names: Vec<String> = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
@@ -268,17 +322,52 @@ pub async fn start_stopped_containers() -> RecoveryReport {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
if all_names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
// Filter out user-stopped containers
|
||||
let user_stopped = load_user_stopped(data_dir).await;
|
||||
let names: Vec<String> = all_names.into_iter()
|
||||
.filter(|n| {
|
||||
if user_stopped.contains(n) {
|
||||
info!("Skipping user-stopped container: {}", n);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport { total: 0, recovered: 0, failed: Vec::new() };
|
||||
}
|
||||
|
||||
info!("Starting {} stopped containers after boot...", names.len());
|
||||
let records: Vec<RunningContainerRecord> = names.iter()
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names.iter()
|
||||
.map(|n| RunningContainerRecord { name: n.clone(), image: String::new() })
|
||||
.collect();
|
||||
records.sort_by_key(|r| container_boot_tier(&r.name));
|
||||
|
||||
info!("Starting {} stopped containers after boot (skipped {} user-stopped)...",
|
||||
records.len(), user_stopped.len());
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match id {
|
||||
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
||||
| "immich_redis" | "penpot-valkey" => 0,
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 1,
|
||||
"lnd" | "electrumx" | "mempool-electrs" | "electrs" | "nbxplorer" => 2,
|
||||
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui"
|
||||
| "penpot-frontend" | "penpot-exporter" => 4,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically saves the container snapshot.
|
||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -102,6 +102,7 @@ pub enum PackageState {
|
||||
Installed,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Exited,
|
||||
Starting,
|
||||
Running,
|
||||
Restarting,
|
||||
@@ -117,6 +118,9 @@ pub enum PackageState {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PackageDataEntry {
|
||||
pub state: PackageState,
|
||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub health: Option<String>,
|
||||
#[serde(rename = "static-files")]
|
||||
pub static_files: StaticFiles,
|
||||
pub manifest: Manifest,
|
||||
|
||||
@@ -350,8 +350,26 @@ async fn restart_container(name: &str) -> bool {
|
||||
/// Spawn the health monitor background task.
|
||||
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
// Wait 2 minutes for containers to start up
|
||||
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
|
||||
// Wait for boot recovery to complete before starting health checks.
|
||||
// This prevents the health monitor from fighting with crash_recovery
|
||||
// which is starting containers in tier order.
|
||||
info!("Health monitor: waiting for boot recovery to complete...");
|
||||
let wait_start = std::time::Instant::now();
|
||||
loop {
|
||||
if crate::crash_recovery::is_recovery_complete() {
|
||||
break;
|
||||
}
|
||||
// Safety timeout: start anyway after 5 minutes even if recovery hangs
|
||||
if wait_start.elapsed().as_secs() > 300 {
|
||||
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
// Additional cooldown after recovery to let containers stabilize
|
||||
info!("Health monitor: recovery done, waiting 60s for containers to stabilize...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
info!("Health monitor: starting health checks");
|
||||
|
||||
let mut tracker = RestartTracker::new();
|
||||
let mut mem_tracker = MemoryTracker::new();
|
||||
@@ -378,6 +396,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load user-stopped list to skip intentionally stopped containers
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&data_dir).await;
|
||||
|
||||
// Sort containers by startup tier so databases restart before dependent services
|
||||
let mut unhealthy: Vec<&ContainerHealth> = Vec::new();
|
||||
let mut state_changed = false;
|
||||
@@ -392,6 +413,11 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
if container.state == "exited" || container.state == "stopped" {
|
||||
// Skip user-stopped containers
|
||||
if user_stopped.contains(&container.name) {
|
||||
debug!("Skipping user-stopped container: {}", container.name);
|
||||
continue;
|
||||
}
|
||||
unhealthy.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,17 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Start any stopped containers (handles clean reboot)
|
||||
let boot_report = crash_recovery::start_stopped_containers().await;
|
||||
// Skips user-stopped containers, uses tier ordering
|
||||
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
|
||||
if boot_report.total > 0 {
|
||||
info!(
|
||||
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
|
||||
boot_report.recovered, boot_report.total, boot_report.failed
|
||||
);
|
||||
}
|
||||
|
||||
// Signal to health monitor that boot recovery is done
|
||||
crash_recovery::mark_recovery_complete();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -121,8 +121,24 @@ impl DwnStore {
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Validate a record ID to prevent path traversal.
|
||||
fn validate_record_id(record_id: &str) -> Result<()> {
|
||||
if record_id.is_empty()
|
||||
|| record_id.len() > 128
|
||||
|| !record_id
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid record ID (alphanumeric, hyphens, underscores only)"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a message by record ID.
|
||||
pub async fn read_message(&self, record_id: &str) -> Result<Option<DwnMessage>> {
|
||||
Self::validate_record_id(record_id)?;
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
@@ -137,6 +153,7 @@ impl DwnStore {
|
||||
|
||||
/// Delete a message by record ID.
|
||||
pub async fn delete_message(&self, record_id: &str) -> Result<bool> {
|
||||
Self::validate_record_id(record_id)?;
|
||||
let path = self.messages_dir.join(format!("{}.json", record_id));
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
|
||||
@@ -322,11 +322,37 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
|
||||
fs::write(&path, data).await.context("Writing router config")
|
||||
}
|
||||
|
||||
/// Validate that an IP string is a private/LAN address (not public, not localhost).
|
||||
fn is_valid_private_ip(ip_str: &str) -> bool {
|
||||
let ip: std::net::IpAddr = match ip_str.parse() {
|
||||
Ok(ip) => ip,
|
||||
Err(_) => return false, // Reject hostnames
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
// Allow only RFC1918 private ranges, reject localhost and public
|
||||
let octets = v4.octets();
|
||||
let is_10 = octets[0] == 10;
|
||||
let is_172_private = octets[0] == 172 && (16..=31).contains(&octets[1]);
|
||||
let is_192_168 = octets[0] == 192 && octets[1] == 168;
|
||||
is_10 || is_172_private || is_192_168
|
||||
}
|
||||
std::net::IpAddr::V6(_) => false, // Reject IPv6 for gateway detection
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect router type by probing common endpoints on the gateway.
|
||||
pub async fn detect_router_type(gateway_ip: &str) -> RouterType {
|
||||
// Validate that gateway is a private IP — prevent SSRF to arbitrary hosts
|
||||
if !is_valid_private_ip(gateway_ip) {
|
||||
tracing::warn!(gateway = gateway_ip, "Rejected non-private gateway IP");
|
||||
return RouterType::Unknown;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
@@ -234,16 +235,31 @@ impl SessionStore {
|
||||
None
|
||||
}
|
||||
|
||||
/// Upgrade a pending session to a full session.
|
||||
pub async fn upgrade_to_full(&self, token: &str) {
|
||||
let hash = hash_token(token);
|
||||
/// Upgrade a pending session to a full session with token rotation.
|
||||
/// Deletes the old pending session and creates a new full session with a fresh token.
|
||||
/// Returns the new plaintext token so the caller can set it as the new cookie.
|
||||
pub async fn upgrade_to_full(&self, token: &str) -> Option<String> {
|
||||
let old_hash = hash_token(token);
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session) = sessions.get_mut(&hash) {
|
||||
session.session_type = SessionType::Full;
|
||||
// Only upgrade if the old session exists and is pending
|
||||
if sessions.remove(&old_hash).is_some() {
|
||||
let new_token_bytes: [u8; 32] = rand::random();
|
||||
let new_token = hex::encode(new_token_bytes);
|
||||
let new_hash = hash_token(&new_token);
|
||||
let now = SystemTime::now();
|
||||
session.created_at = now;
|
||||
session.last_activity = now;
|
||||
self.evict_if_over_limit(&mut sessions);
|
||||
sessions.insert(
|
||||
new_hash,
|
||||
Session {
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
session_type: SessionType::Full,
|
||||
},
|
||||
);
|
||||
Self::save_to_disk_sync(&sessions, &self.persist_path);
|
||||
Some(new_token)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,25 +409,21 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn load_or_create_remember_secret() -> Vec<u8> {
|
||||
// Try existing secret file first (backwards compatibility)
|
||||
// Try existing secret file first
|
||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||
if secret.len() == 32 {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
// Derive a deterministic secret from machine-id so it survives restarts
|
||||
// without storing plaintext key material
|
||||
let machine_id = std::fs::read_to_string("/etc/machine-id")
|
||||
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
|
||||
let salt = b"archipelago-remember-me-v1";
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
use sha2::Digest;
|
||||
hasher.update(machine_id.trim().as_bytes());
|
||||
hasher.update(salt);
|
||||
let secret = hasher.finalize();
|
||||
let secret_vec = secret.to_vec();
|
||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret_vec);
|
||||
secret_vec
|
||||
// Generate a cryptographically random 32-byte secret on first boot
|
||||
let mut secret = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(REMEMBER_SECRET_FILE, &secret);
|
||||
secret.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,9 +617,15 @@ mod tests {
|
||||
let got = store.get_pending_secret(&token).await;
|
||||
assert_eq!(got, Some(secret));
|
||||
|
||||
// Upgrade to full
|
||||
store.upgrade_to_full(&token).await;
|
||||
assert!(store.validate(&token).await);
|
||||
// Upgrade to full — returns a new rotated token
|
||||
let new_token = store.upgrade_to_full(&token).await;
|
||||
assert!(new_token.is_some());
|
||||
let new_token = new_token.unwrap();
|
||||
|
||||
// Old token should be invalid (rotated)
|
||||
assert!(!store.validate(&token).await);
|
||||
// New token should be valid
|
||||
assert!(store.validate(&new_token).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -124,6 +124,7 @@ async fn send_http_webhook(
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user