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::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RpcRequest {
|
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
|
// Rate limit login attempts
|
||||||
if rpc_req.method == "auth.login" {
|
if rpc_req.method == "auth.login" {
|
||||||
let client_ip = extract_client_ip(&parts.headers);
|
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(Some(totp_data)) = self.auth_manager.get_totp_data().await {
|
||||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
||||||
let token = self.session_store.create_pending(secret).await;
|
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",
|
"Set-Cookie",
|
||||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap(),
|
.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
|
// Override the response body to indicate TOTP is required
|
||||||
let totp_body = serde_json::json!({
|
let totp_body = serde_json::json!({
|
||||||
"result": { "requires_totp": true },
|
"result": { "requires_totp": true },
|
||||||
@ -407,31 +446,42 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
// No 2FA: create a full session immediately
|
// No 2FA: create a full session immediately
|
||||||
let token = self.session_store.create().await;
|
let token = self.session_store.create().await;
|
||||||
response.headers_mut().insert(
|
let csrf_token = generate_csrf_token();
|
||||||
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap(),
|
.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
|
// On successful TOTP verification, the session is already upgraded to full
|
||||||
// (handled inside handle_login_totp/handle_login_backup)
|
// (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 rpc_req.method == "auth.logout" {
|
||||||
if let Some(token) = &session_token {
|
if let Some(token) = &session_token {
|
||||||
self.session_store.remove(token).await;
|
self.session_store.remove(token).await;
|
||||||
}
|
}
|
||||||
let logout_cookie = if self.config.dev_mode {
|
let secure_suffix = if self.config.dev_mode { "" } else { "; Secure" };
|
||||||
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string()
|
response.headers_mut().append(
|
||||||
} else {
|
|
||||||
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0; Secure".to_string()
|
|
||||||
};
|
|
||||||
response.headers_mut().insert(
|
|
||||||
"Set-Cookie",
|
"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).
|
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||||
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||||
headers
|
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.
|
- [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.
|
- [ ] **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 {
|
class RPCClient {
|
||||||
private baseUrl: string
|
private baseUrl: string
|
||||||
|
|
||||||
@ -31,12 +36,18 @@ class RPCClient {
|
|||||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
try {
|
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, {
|
const response = await fetch(this.baseUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include', // Important for session cookies
|
credentials: 'include', // Important for session cookies
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ method, params }),
|
body: JSON.stringify({ method, params }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user