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:
co-authored by
Claude Opus 4.6
parent
db2ad27340
commit
1a31c33ae8
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user