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 d1b48388fb
commit 1a74a930f7
77 changed files with 2485 additions and 966 deletions
+42 -24
View File
@@ -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]