use crate::session::SessionStore; use std::net::IpAddr; /// Methods that do not require a valid session cookie. pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[ "auth.login", "auth.login.totp", "auth.login.backup", "auth.isOnboardingComplete", "auth.isSetup", "auth.setup", "auth.onboardingComplete", "health", // Server readiness check (Login.vue polls this before showing form) "server.echo", // Onboarding flow (before user has a session — DID creation, signing, backup) "node.did", "node.signChallenge", "node.nostr-pubkey", "node.createBackup", "identity.create", "identity.verify", "identity.resolve-did", // Seed management (onboarding — before user has a session) "seed.generate", "seed.verify", "seed.restore", "seed.save-encrypted", // Onboarding restore (before user account exists) "backup.restore-identity", // Inter-node RPC: called by federated peers over Tor, no session cookies "federation.peer-joined", "federation.peer-address-changed", "federation.peer-did-changed", "federation.get-state", // Fleet telemetry ingest: called by remote nodes posting reports "telemetry.ingest", ]; /// Methods whose responses can be cached for a few seconds. pub(super) const CACHEABLE_METHODS: &[&str] = &["system.stats", "federation.list-nodes"]; /// Sanitize error messages before returning to clients. /// Keeps user-facing validation errors but strips internal system details. pub(super) fn sanitize_error_message(msg: &str) -> String { // Allow known validation errors through (these are user-actionable) let user_facing_prefixes = [ "Invalid", "Missing", "Not found", "Already exists", "Rate limit", "Unauthorized", "Forbidden", "Not supported", "Requires", "requires", "must be", "cannot", "Password", "Session", "Failed to pull", "Failed to start", "Container", "Image", "Bitcoin address", "No router", "No OpenWrt", "No space left", "Not enough flash", "Not enough space", "TollGate installation failed", "No pre-built TollGate", "opkg not found", "apk update failed", "No wireless interface", "No wireless radio", "WiFi radio enabled but", "Missing required field", // seed.reveal / auth flows — user-actionable, no internals to leak. // Without these the sanitizer collapsed every reveal failure into // "Operation failed. Check server logs." (which isn't even a crash). "Incorrect", "This node has no encrypted seed", "A 2FA code is required", "2FA is enabled but", "Could not decrypt the saved seed", "Could not unlock 2FA", "No mnemonic available", "No pending seed generation", "Submitted words", "Already set up", ]; for prefix in &user_facing_prefixes { if msg.starts_with(prefix) { // Truncate long messages and strip file paths let sanitized = msg .replace("/var/lib/archipelago/", "[data]/") .replace("/usr/local/bin/", "[bin]/") .replace("/etc/", "[config]/"); return if sanitized.len() > 200 { format!("{}...", &sanitized[..200]) } else { sanitized }; } } // For all other errors, return a generic message "Operation failed. Check server logs for details.".to_string() } #[cfg(test)] mod sanitize_tests { use super::sanitize_error_message; #[test] fn seed_reveal_errors_pass_through() { // Every user-actionable seed.reveal failure must reach the user — // masking them as "Check server logs" sent a real user hunting a // crash that never happened. for msg in [ "Incorrect password", "This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.", "A 2FA code is required to reveal the recovery phrase", "2FA is enabled but no TOTP data found", "Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.", "Could not unlock 2FA with this password", "No mnemonic available. Generate or restore a seed first.", "Submitted words do not match generated seed", "Already set up. Use auth.changePassword to change.", ] { assert_ne!( sanitize_error_message(msg), "Operation failed. Check server logs for details.", "masked: {msg}" ); } } #[test] fn internal_errors_stay_generic() { assert_eq!( sanitize_error_message("thread panicked at src/foo.rs:42"), "Operation failed. Check server logs for details." ); } } /// Derive a CSRF token from the session token via HMAC. /// Deterministic: same session token always produces the same CSRF token. /// Survives backend restarts because it depends only on the session token /// and the on-disk remember secret (not ephemeral state). pub(super) async fn derive_csrf_token(session_token: &str) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; type HmacSha256 = Hmac; let secret = SessionStore::load_or_create_remember_secret().await; let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key"); mac.update(format!("csrf:{}", session_token).as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Extract a named cookie value from headers. pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option { let prefix = format!("{}=", name); for value in headers.get_all("cookie") { if let Ok(s) = value.to_str() { for part in s.split(';') { let part = part.trim(); if let Some(val) = part.strip_prefix(&prefix) { let val = val.trim(); if !val.is_empty() { return Some(val.to_string()); } } } } } None } /// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For). pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr { headers .get("x-real-ip") .or_else(|| headers.get("x-forwarded-for")) .and_then(|v| v.to_str().ok()) .and_then(|s| s.split(',').next()) .and_then(|s| s.trim().parse::().ok()) .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) }