From a7653d4c8b146209da6c9ff9b0d71264b5fccefb Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 11 Mar 2026 00:46:52 +0000 Subject: [PATCH] feat: implement CSRF protection on RPC layer Double-submit cookie pattern: backend generates csrf_token cookie on login (non-HttpOnly so JS can read it), validates X-CSRF-Token header matches cookie on all authenticated RPC calls. Returns 403 if missing/mismatched. Frontend reads cookie and sends header automatically. Co-Authored-By: Claude Opus 4.6 --- core/archipelago/src/api/rpc/mod.rs | 95 ++++++++++++++++++++++++++--- loop/plan.md | 2 +- neode-ui/src/api/rpc-client.ts | 17 +++++- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 8dcb41d7..5ceaa375 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -32,6 +32,7 @@ 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 { @@ -147,6 +148,37 @@ impl RpcHandler { } } + // CSRF protection: validate X-CSRF-Token header for authenticated methods + if !is_unauthenticated { + 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 */ } + _ => { + 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()); + } + } + } + // Rate limit login attempts if rpc_req.method == "auth.login" { let client_ip = extract_client_ip(&parts.headers); @@ -388,12 +420,19 @@ 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; - response.headers_mut().insert( + let csrf_token = generate_csrf_token(); + response.headers_mut().append( "Set-Cookie", format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix()) .parse() .unwrap(), ); + response.headers_mut().append( + "Set-Cookie", + format!("csrf_token={}; SameSite=Strict; Path=/{}", csrf_token, self.cookie_suffix()) + .parse() + .unwrap(), + ); // Override the response body to indicate TOTP is required let totp_body = serde_json::json!({ "result": { "requires_totp": true }, @@ -407,31 +446,42 @@ impl RpcHandler { } else { // No 2FA: create a full session immediately let token = self.session_store.create().await; - response.headers_mut().insert( + let csrf_token = generate_csrf_token(); + response.headers_mut().append( "Set-Cookie", format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix()) .parse() .unwrap(), ); + response.headers_mut().append( + "Set-Cookie", + format!("csrf_token={}; SameSite=Strict; Path=/{}", csrf_token, self.cookie_suffix()) + .parse() + .unwrap(), + ); } } // On successful TOTP verification, the session is already upgraded to full // (handled inside handle_login_totp/handle_login_backup) - // On logout, invalidate session and expire the cookie + // On logout, invalidate session and expire cookies if rpc_req.method == "auth.logout" { if let Some(token) = &session_token { self.session_store.remove(token).await; } - let logout_cookie = if self.config.dev_mode { - "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string() - } else { - "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0; Secure".to_string() - }; - response.headers_mut().insert( + let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" }; + response.headers_mut().append( "Set-Cookie", - logout_cookie.parse().unwrap(), + format!("session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0{}", secure_suffix) + .parse() + .unwrap(), + ); + response.headers_mut().append( + "Set-Cookie", + format!("csrf_token=; SameSite=Strict; Path=/; Max-Age=0{}", secure_suffix) + .parse() + .unwrap(), ); } @@ -448,6 +498,31 @@ 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) +} + +/// Extract the csrf_token cookie value from headers. +fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option { + 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("csrf_token=") { + 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). fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr { headers diff --git a/loop/plan.md b/loop/plan.md index 7caa0969..2ce5bba3 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -56,7 +56,7 @@ - [x] **BACK-04** — Add WiFi/Ethernet UI to Server.vue. Add a "Network Interfaces" section to Server.vue showing detected interfaces with their IPs and statuses. For WiFi, add "Scan & Connect" button that opens a modal listing available networks. For Ethernet, show DHCP/Static toggle. Use `glass-card` container with `bg-white/5` sub-rows. **Acceptance**: Real network interfaces visible on Server page; WiFi scan works on dev server. Deploy and verify. -- [ ] **BACK-05** — Implement CSRF protection on RPC layer. Address the High-severity finding from `docs/security-audit-2026-03-05.md`. Add CSRF token generation on login (return as cookie + response field), validate on all state-changing RPC calls. In `core/archipelago/src/api/rpc/mod.rs`, add `X-CSRF-Token` header check for non-GET methods. In `neode-ui/src/api/rpc-client.ts`, read the CSRF cookie and send it as header. **Acceptance**: RPC calls without CSRF token return 403; calls with correct token succeed. +- [x] **BACK-05** — Implement CSRF protection on RPC layer. Address the High-severity finding from `docs/security-audit-2026-03-05.md`. Add CSRF token generation on login (return as cookie + response field), validate on all state-changing RPC calls. In `core/archipelago/src/api/rpc/mod.rs`, add `X-CSRF-Token` header check for non-GET methods. In `neode-ui/src/api/rpc-client.ts`, read the CSRF cookie and send it as header. **Acceptance**: RPC calls without CSRF token return 403; calls with correct token succeed. - [ ] **BACK-06** — Fix CORS policy: restrict to same-origin. Address the High-severity CORS finding. In `core/archipelago/src/server.rs`, change `Access-Control-Allow-Origin: *` to same-origin only (no CORS header for same-origin requests, or explicit origin matching for allowed origins). **Acceptance**: Cross-origin requests from unknown origins are rejected. diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index dc8754c1..df2e022a 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -15,6 +15,11 @@ export interface RPCResponse { } } +function getCsrfToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/) + return match ? match[1]! : null +} + class RPCClient { private baseUrl: string @@ -31,12 +36,18 @@ class RPCClient { const timeoutId = setTimeout(() => controller.abort(), timeout) try { + const headers: Record = { + 'Content-Type': 'application/json', + } + const csrfToken = getCsrfToken() + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken + } + const response = await fetch(this.baseUrl, { method: 'POST', credentials: 'include', // Important for session cookies - headers: { - 'Content-Type': 'application/json', - }, + headers, body: JSON.stringify({ method, params }), signal: controller.signal, })