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
co-authored by Claude Opus 4.6
parent 28763c4f09
commit 84a56c80de
77 changed files with 2485 additions and 966 deletions
+6 -3
View File
@@ -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.
+15 -1
View File
@@ -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);
+24 -7
View File
@@ -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
+2 -1
View File
@@ -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");
}
}
+58 -4
View File
@@ -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() {
+14
View File
@@ -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(),
+12
View File
@@ -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;
+16
View File
@@ -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())
+8 -6
View File
@@ -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");
+109 -27
View File
@@ -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();
}