fix: BUG-1 CSRF, TASK-8 H2/H3/H4, BUG-20/37/40/41 — 7 bugs fixed

BUG-1 (P0): CSRF tokens now HMAC-derived from session token instead of
random — survives backend restarts, eliminates cookie/header race conditions.
Frontend retries 403s as belt-and-suspenders.

TASK-8 H2: federation.peer-joined verifies ed25519 signature on join messages.
TASK-8 H3: federation.peer-address-changed requires signed proof from known peer.
TASK-8 H4: Rust backend default bind 0.0.0.0 → 127.0.0.1 (nginx proxies all).

BUG-20: ElectrumX index estimate string fixed from ~55GB to ~130GB.
BUG-37: App card Start/Stop buttons split into loading vs interactive states
        to prevent WebSocket state flicker during container scans.
BUG-40: Uninstall modal uses Teleport to body with z-[3000] for full overlay.
BUG-41: Uninstalling overlay on card + optimistic store removal.

Updated MASTER_PLAN.md and BETA-PROGRESS.md to reflect all completed work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 22:05:21 +00:00
co-authored by Claude Opus 4.6
parent db2ad27340
commit 1a31c33ae8
12 changed files with 404 additions and 271 deletions
+60 -36
View File
@@ -44,7 +44,6 @@ use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use tracing::{debug, error};
use rand::Rng;
#[derive(Debug, Deserialize)]
struct RpcRequest {
@@ -254,7 +253,7 @@ impl RpcHandler {
if crate::session::SessionStore::validate_remember_token(&remember) {
// Auto-create a new session from the remember-me token
let new_token = self.session_store.create().await;
let new_csrf = generate_csrf_token();
let new_csrf = derive_csrf_token(&new_token);
tracing::info!("Auto-restored session from remember-me token");
new_session_cookies = Some((new_token, new_csrf));
authenticated = true;
@@ -306,41 +305,59 @@ impl RpcHandler {
}
}
// CSRF protection: validate X-CSRF-Token header for authenticated methods
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response)
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// The expected CSRF value is derived deterministically from the session token, so it
// survives backend restarts and eliminates cookie/header race conditions.
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response).
if !is_unauthenticated && new_session_cookies.is_none() {
let csrf_cookie = extract_csrf_cookie(&parts.headers);
let csrf_header = parts
.headers
.get("x-csrf-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match (&csrf_cookie, &csrf_header) {
(Some(cookie), Some(header)) if cookie == header => { /* valid */ }
_ => {
tracing::warn!(
method = %rpc_req.method,
has_cookie = csrf_cookie.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF mismatch — rejecting RPC call"
);
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
code: 403,
message: "CSRF token missing or invalid".to_string(),
data: None,
}),
let csrf_valid = match (&session_token, &csrf_header) {
(Some(token), Some(header)) => {
// Verify using HMAC — constant-time comparison built-in
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let secret = SessionStore::load_or_create_remember_secret();
let mut mac = match HmacSha256::new_from_slice(&secret) {
Ok(m) => m,
Err(_) => { return Ok(Response::builder().status(500).body(hyper::Body::empty()).unwrap()); }
};
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap());
mac.update(format!("csrf:{}", token).as_bytes());
match hex::decode(header) {
Ok(header_bytes) => mac.verify_slice(&header_bytes).is_ok(),
Err(_) => false,
}
}
_ => false,
};
if !csrf_valid {
tracing::warn!(
method = %rpc_req.method,
has_session = session_token.is_some(),
has_header = csrf_header.is_some(),
"403 CSRF validation failed — rejecting RPC call"
);
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
code: 403,
message: "CSRF token missing or invalid".to_string(),
data: None,
}),
};
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header("Content-Type", "application/json")
.body(hyper::Body::from(resp_body))
.unwrap());
}
}
@@ -825,7 +842,7 @@ impl RpcHandler {
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
let token = self.session_store.create_pending(secret).await;
let csrf_token = generate_csrf_token();
let csrf_token = derive_csrf_token(&token);
response.headers_mut().append(
"Set-Cookie",
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
@@ -851,7 +868,7 @@ impl RpcHandler {
} else {
// No 2FA: create a full session immediately
let token = self.session_store.create().await;
let csrf_token = generate_csrf_token();
let csrf_token = derive_csrf_token(&token);
let remember_token = self.session_store.create_remember_token();
response.headers_mut().append(
"Set-Cookie",
@@ -882,7 +899,7 @@ impl RpcHandler {
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
if let Some(token) = &session_token {
let new_token = self.session_store.rotate(token).await;
let csrf_token = generate_csrf_token();
let csrf_token = derive_csrf_token(&new_token);
response.headers_mut().append(
"Set-Cookie",
format!(
@@ -956,11 +973,18 @@ impl RpcHandler {
}
}
/// Generate a random CSRF token (32-byte hex string).
fn generate_csrf_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill(&mut bytes);
hex::encode(bytes)
/// 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).
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();
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.