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 <noreply@anthropic.com>
This commit is contained in:
parent
b9efd1b3d0
commit
a7653d4c8b
@ -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<String> {
|
||||
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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -15,6 +15,11 @@ export interface RPCResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
'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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user