- image: create archipelago user with dialout so the backend can open /dev/ttyUSB*/ttyACM* LoRa radios; doctor Fix 14 heals existing nodes (verified live on the .65 RC install — device detected after the fix) - Web5Federation.vue called nonexistent federation.list (Unknown method); now uses federation.list-nodes + federation.list-pending-requests with a last-seen-based online count - sanitizer: let app-install dependency errors through — 'LND requires a running Bitcoin node' was masked as 'Check server logs', so install failures on fresh nodes looked random Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
9.9 KiB
Rust
280 lines
9.9 KiB
Rust
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",
|
|
"Failed to open channel",
|
|
"Failed to close channel",
|
|
"Failed to connect to peer",
|
|
// App-install dependency errors (package/dependencies.rs) — masking
|
|
// these left users retrying installs blind ("LND install failed" on a
|
|
// fresh node was really "Bitcoin Knots isn't running yet")
|
|
"LND requires",
|
|
"ElectrumX requires",
|
|
"BTCPay Server requires",
|
|
"Mempool requires",
|
|
"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<Sha256>;
|
|
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<String> {
|
|
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
|
|
}
|
|
|
|
/// The TCP peer address of the connection a request arrived on, injected
|
|
/// into request extensions by the server accept loop.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct PeerAddr(pub std::net::SocketAddr);
|
|
|
|
/// Extract the client IP for rate limiting.
|
|
///
|
|
/// `X-Real-IP`/`X-Forwarded-For` are only honored when the connection
|
|
/// itself comes from loopback — i.e. from our local nginx, which sets
|
|
/// `X-Real-IP $remote_addr`. On a direct connection (the FIPS peer
|
|
/// listener, or anything that isn't the local proxy) the headers are
|
|
/// client-supplied, so trusting them let an attacker rotate per-request
|
|
/// "IPs" and defeat the login rate limiter; there we use the socket
|
|
/// address instead.
|
|
pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
|
|
let socket_ip = parts.extensions.get::<PeerAddr>().map(|p| p.0.ip());
|
|
match socket_ip {
|
|
Some(ip) if ip.is_loopback() => forwarded_client_ip(&parts.headers).unwrap_or(ip),
|
|
Some(ip) => ip,
|
|
// No socket info recorded (shouldn't happen in the server path);
|
|
// fall back to the pre-extension behavior.
|
|
None => forwarded_client_ip(&parts.headers)
|
|
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
|
}
|
|
}
|
|
|
|
/// The proxy-reported client IP, if a forwarded header carries one.
|
|
fn forwarded_client_ip(headers: &hyper::HeaderMap) -> Option<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::<IpAddr>().ok())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod client_ip_tests {
|
|
use super::*;
|
|
use std::net::SocketAddr;
|
|
|
|
fn parts_with(
|
|
peer: Option<&str>,
|
|
real_ip: Option<&str>,
|
|
) -> hyper::http::request::Parts {
|
|
let mut builder = hyper::Request::builder().uri("/rpc/v1");
|
|
if let Some(ip) = real_ip {
|
|
builder = builder.header("x-real-ip", ip);
|
|
}
|
|
let (mut parts, _) = builder.body(()).unwrap().into_parts();
|
|
if let Some(addr) = peer {
|
|
parts
|
|
.extensions
|
|
.insert(PeerAddr(addr.parse::<SocketAddr>().unwrap()));
|
|
}
|
|
parts
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_connection_trusts_forwarded_header() {
|
|
// nginx on loopback forwards the real client IP — use it.
|
|
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
|
|
assert_eq!(
|
|
extract_client_ip(&parts),
|
|
"192.168.1.50".parse::<IpAddr>().unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_connection_ignores_spoofed_header() {
|
|
// A direct (non-proxy) client rotating X-Real-IP per request must
|
|
// still bucket under its socket address.
|
|
let parts = parts_with(Some("203.0.113.9:9999"), Some("10.0.0.1"));
|
|
assert_eq!(
|
|
extract_client_ip(&parts),
|
|
"203.0.113.9".parse::<IpAddr>().unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_connection_without_header_uses_socket_ip() {
|
|
let parts = parts_with(Some("127.0.0.1:5000"), None);
|
|
assert_eq!(
|
|
extract_client_ip(&parts),
|
|
"127.0.0.1".parse::<IpAddr>().unwrap()
|
|
);
|
|
}
|
|
}
|