Skip to content

Commit c336041

Browse files
committed
Security audit: fix 14 vulnerabilities
Critical fixes: - DERP relay: verify node_secret against database (was format-only check) - Integer overflow protection in IP allocation with checked_add() - WebSocket: require authentication for all connections - Race condition in 2FA backup codes: optimistic locking High severity fixes: - Rate limiter: MAX_BUCKETS limit prevents memory exhaustion - DERP packet validation: proper length checks - Information disclosure: generic error messages to clients - Public key validation: full base64 decode + 32 byte check - Invite codes: default max_uses=10 instead of unlimited Medium severity fixes: - Email format validation - Network/Node name length validation (1-100 chars) - CIDR format validation - API key timing attack mitigation (fetch_optional + random delay) - Predictable key file path: random suffix added Frontend: - WebSocket store: include auth token in connection URL
1 parent 9ab138d commit c336041

File tree

11 files changed

+324
-81
lines changed

11 files changed

+324
-81
lines changed

crates/burrow-agent/src/wireguard.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ impl WireGuard {
7373
}
7474

7575
// Set private key via temp file (wg requires file input)
76-
// Use secure permissions (0600) to prevent other users from reading
77-
let key_file = "/tmp/burrow_wg_key";
76+
// Use secure permissions (0600) and random suffix to prevent race conditions
77+
use rand::Rng;
78+
let random_suffix: u64 = rand::thread_rng().gen();
79+
let key_file = format!("/tmp/burrow_wg_key_{:016x}", random_suffix);
7880

7981
#[cfg(unix)]
8082
{
@@ -83,23 +85,23 @@ impl WireGuard {
8385
.create(true)
8486
.truncate(true)
8587
.mode(0o600)
86-
.open(key_file)
88+
.open(&key_file)
8789
.context("Failed to create secure key file")?;
8890
file.write_all(self.config.private_key.as_bytes())?;
8991
}
9092

9193
#[cfg(not(unix))]
9294
{
93-
std::fs::write(key_file, &self.config.private_key)?;
95+
std::fs::write(&key_file, &self.config.private_key)?;
9496
}
95-
97+
9698
let wg_result = Command::new("sudo")
97-
.args(["wg", "set", iface, "private-key", key_file, "listen-port",
99+
.args(["wg", "set", iface, "private-key", &key_file, "listen-port",
98100
&self.config.listen_port.to_string()])
99101
.status();
100102

101103
// Always remove temp key file, even on error
102-
let _ = std::fs::remove_file(key_file);
104+
let _ = std::fs::remove_file(&key_file);
103105

104106
let status = wg_result.context("Failed to set WireGuard private key")?;
105107

crates/burrow-server/src/auth.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,26 +147,43 @@ pub async fn admin_middleware(
147147
}
148148

149149
/// Validate API key and return claims
150+
/// Uses fetch_optional + constant-time comparison to prevent timing attacks
150151
async fn validate_api_key(state: &AppState, api_key: &str) -> anyhow::Result<Claims> {
152+
// Validate API key format first (brw_ prefix + 32 chars)
153+
if !api_key.starts_with("brw_") || api_key.len() != 36 {
154+
// Add small random delay to prevent timing-based format detection
155+
use rand::Rng;
156+
let delay_ms = rand::thread_rng().gen_range(1..5);
157+
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
158+
anyhow::bail!("Invalid API key format");
159+
}
160+
161+
// Use fetch_optional to always take the same time whether key exists or not
151162
let row = sqlx::query_as::<_, (String, String, String)>(
152163
"SELECT user_id, email, role FROM api_keys WHERE key = ? AND revoked = 0"
153164
)
154165
.bind(api_key)
155-
.fetch_one(&state.db)
166+
.fetch_optional(&state.db)
156167
.await?;
157168

158-
// Update last_used timestamp
159-
sqlx::query("UPDATE api_keys SET last_used = ? WHERE key = ?")
160-
.bind(Utc::now().to_rfc3339())
161-
.bind(api_key)
162-
.execute(&state.db)
163-
.await
164-
.ok();
169+
let (user_id, email, role) = row.ok_or_else(|| anyhow::anyhow!("Invalid API key"))?;
170+
171+
// Update last_used timestamp (fire-and-forget)
172+
let db = state.db.clone();
173+
let key = api_key.to_string();
174+
tokio::spawn(async move {
175+
sqlx::query("UPDATE api_keys SET last_used = ? WHERE key = ?")
176+
.bind(Utc::now().to_rfc3339())
177+
.bind(&key)
178+
.execute(&db)
179+
.await
180+
.ok();
181+
});
165182

166183
Ok(Claims {
167-
sub: row.0,
168-
email: row.1,
169-
role: row.2,
184+
sub: user_id,
185+
email,
186+
role,
170187
exp: i64::MAX,
171188
iat: Utc::now().timestamp(),
172189
})

crates/burrow-server/src/auth_handlers.rs

Lines changed: 115 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,41 @@ use crate::audit::{log_event, AuditEvent, AuditEventType};
1515
use crate::auth::{self, Claims};
1616
use crate::state::AppState;
1717

18+
// === Validation helpers ===
19+
20+
/// Simple email validation (checks for @ and . in domain)
21+
fn is_valid_email(email: &str) -> bool {
22+
let email = email.trim();
23+
if email.len() > 254 || email.is_empty() {
24+
return false;
25+
}
26+
27+
let parts: Vec<&str> = email.split('@').collect();
28+
if parts.len() != 2 {
29+
return false;
30+
}
31+
32+
let (local, domain) = (parts[0], parts[1]);
33+
34+
// Local part validation
35+
if local.is_empty() || local.len() > 64 {
36+
return false;
37+
}
38+
39+
// Domain validation
40+
if domain.is_empty() || !domain.contains('.') || domain.len() > 253 {
41+
return false;
42+
}
43+
44+
// Domain parts must not be empty
45+
let domain_parts: Vec<&str> = domain.split('.').collect();
46+
if domain_parts.iter().any(|p| p.is_empty()) {
47+
return false;
48+
}
49+
50+
true
51+
}
52+
1853
// === Request/Response types ===
1954

2055
#[derive(Deserialize)]
@@ -84,6 +119,22 @@ pub async fn register(
84119
State(state): State<Arc<AppState>>,
85120
Json(req): Json<RegisterRequest>,
86121
) -> Result<Json<AuthResponse>, AuthHandlerError> {
122+
// Validate email format
123+
if !is_valid_email(&req.email) {
124+
return Err(AuthHandlerError(
125+
StatusCode::BAD_REQUEST,
126+
"Invalid email format".to_string(),
127+
));
128+
}
129+
130+
// Validate name
131+
if req.name.trim().is_empty() || req.name.len() > 100 {
132+
return Err(AuthHandlerError(
133+
StatusCode::BAD_REQUEST,
134+
"Name must be between 1 and 100 characters".to_string(),
135+
));
136+
}
137+
87138
// Check if user exists
88139
let existing = sqlx::query_scalar::<_, i32>(
89140
"SELECT COUNT(*) FROM users WHERE email = ?"
@@ -225,41 +276,8 @@ pub async fn login(
225276
.map_err(|e| AuthHandlerError(StatusCode::INTERNAL_SERVER_ERROR, e))?;
226277

227278
if !valid_totp {
228-
// Try backup code
229-
let mut used_backup = false;
230-
if let Some(ref codes_json) = backup_codes {
231-
if let Ok(mut codes) = serde_json::from_str::<Vec<String>>(codes_json) {
232-
if let Some(pos) = codes.iter().position(|c| c == &totp_code) {
233-
// Remove used backup code
234-
codes.remove(pos);
235-
let updated_codes = serde_json::to_string(&codes).unwrap_or_default();
236-
237-
// Update backup codes in DB
238-
sqlx::query("UPDATE users SET backup_codes = ? WHERE id = ?")
239-
.bind(&updated_codes)
240-
.bind(&user_id)
241-
.execute(&state.db)
242-
.await
243-
.ok();
244-
245-
used_backup = true;
246-
247-
// Audit log - backup code used
248-
log_event(
249-
&state.db,
250-
AuditEvent::new(AuditEventType::SettingsChanged)
251-
.with_user(&user_id, &email)
252-
.with_details(serde_json::json!({
253-
"action": "backup_code_used",
254-
"remaining_codes": codes.len()
255-
})),
256-
)
257-
.await;
258-
259-
tracing::info!("Backup code used for user {}, {} codes remaining", email, codes.len());
260-
}
261-
}
262-
}
279+
// Try backup code with atomic update to prevent race conditions
280+
let used_backup = try_use_backup_code(&state.db, &user_id, &email, &totp_code, &backup_codes).await;
263281

264282
if !used_backup {
265283
// Audit log - failed 2FA
@@ -673,3 +691,65 @@ pub async fn totp_status(
673691
verified: totp_verified == 1,
674692
}))
675693
}
694+
695+
/// Atomically try to use a backup code with race condition protection.
696+
/// Uses optimistic locking by re-checking backup codes during update.
697+
async fn try_use_backup_code(
698+
db: &sqlx::SqlitePool,
699+
user_id: &str,
700+
email: &str,
701+
code: &str,
702+
backup_codes: &Option<String>,
703+
) -> bool {
704+
let Some(codes_json) = backup_codes else {
705+
return false;
706+
};
707+
708+
let Ok(codes) = serde_json::from_str::<Vec<String>>(codes_json) else {
709+
return false;
710+
};
711+
712+
let Some(pos) = codes.iter().position(|c| c == code) else {
713+
return false;
714+
};
715+
716+
// Remove used backup code
717+
let mut new_codes = codes.clone();
718+
new_codes.remove(pos);
719+
let updated_codes_json = serde_json::to_string(&new_codes).unwrap_or_default();
720+
721+
// Atomic update with optimistic locking:
722+
// Only update if backup_codes still matches what we read (prevents race condition)
723+
let result = sqlx::query(
724+
"UPDATE users SET backup_codes = ? WHERE id = ? AND backup_codes = ?"
725+
)
726+
.bind(&updated_codes_json)
727+
.bind(user_id)
728+
.bind(codes_json)
729+
.execute(db)
730+
.await;
731+
732+
match result {
733+
Ok(r) if r.rows_affected() > 0 => {
734+
// Successfully used backup code
735+
log_event(
736+
db,
737+
AuditEvent::new(AuditEventType::SettingsChanged)
738+
.with_user(user_id, email)
739+
.with_details(serde_json::json!({
740+
"action": "backup_code_used",
741+
"remaining_codes": new_codes.len()
742+
})),
743+
)
744+
.await;
745+
746+
tracing::info!("Backup code used for user {}, {} codes remaining", email, new_codes.len());
747+
true
748+
}
749+
_ => {
750+
// Either DB error or race condition (backup_codes changed)
751+
tracing::warn!("Failed to use backup code for user {} - possible race condition", email);
752+
false
753+
}
754+
}
755+
}

0 commit comments

Comments
 (0)