extract_client_ip took X-Real-IP/X-Forwarded-For from any request, so a client talking to the backend directly (the FIPS peer listener, or any non-proxy path) could rotate a fake IP per request and never trip the login rate limiter. The accept loop now records the TCP peer address in request extensions, and forwarded headers are honored only when the connection itself is from loopback — where nginx overwrites X-Real-IP with the real client address. Direct connections bucket under their socket IP. §C of the 1.8.0 hardening plan; 3 new unit tests cover the loopback/direct/no-header matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
9.4 KiB
Rust
270 lines
9.4 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",
|
|
"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()
|
|
);
|
|
}
|
|
}
|