Private
Public Access
1
0

fix: replace broken DashMap rate limiting with tower-governor middleware
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 1m1s
CI Pipeline / Rust Unit Tests (push) Successful in 1m21s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped

- Replace custom DashMap<IpAddr, Instant> rate limiting in enrollment.rs
  that fell back to 0.0.0.0 when X-Forwarded-For was missing, causing
  ALL enrollment traffic to share a single global rate limit bucket
- Use tower_governor with SmartIpKeyExtractor for proper per-IP rate
  limiting that respects X-Forwarded-For headers (critical behind HAProxy)
- Add three configurable rate limit tiers via config.toml:
  * Enrollment: 5 req/min per IP, burst 3 (strict)
  * Auth: 20 req/min per IP, burst 10 (moderate)
  * API: 120 req/min per IP, burst 30 (normal)
- Remove enrollment_rate_limits from AppState and cleanup task
- Remove manual rate limit code from enrollment.rs (headers param, IP extraction)
- Add into_make_service_with_connect_info for ConnectInfo fallback
- Add RateLimitConfig to AppConfig with sensible defaults

Fixes: #1
This commit is contained in:
2026-05-21 02:27:10 +00:00
parent 6c72dc3ac6
commit 59794bc8f2
7 changed files with 395 additions and 79 deletions

View File

@ -1,7 +1,7 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
@ -16,8 +16,6 @@ use pm_core::{
};
use rand::{distributions::Alphanumeric, Rng};
use serde::Serialize;
use std::net::IpAddr;
use std::time::Instant;
#[derive(Debug, Clone, Serialize)]
pub struct HostConflict {
@ -34,43 +32,12 @@ pub fn router() -> Router<AppState> {
/// POST /api/v1/enroll
/// Initiates host self-enrollment.
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
async fn enroll_host(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateEnrollmentRequest>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
// 1. IP-based Rate Limiting
// Prefer real IP from headers if behind proxy (e.g., X-Forwarded-For)
let ip = headers
.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.split(',').next())
.and_then(|h| h.trim().parse::<IpAddr>().ok())
.unwrap_or_else(|| {
tracing::warn!(
"No X-Forwarded-For header found for enrollment request from public endpoint"
);
// Default to a placeholder IP since we can't extract the socket addr without the ConnectInfo layer
"0.0.0.0".parse().unwrap()
});
{
let mut rate_limits = state
.enrollment_rate_limits
.entry(ip)
.or_insert(Instant::now() - std::time::Duration::from_secs(3600));
let last_request = rate_limits.value();
if last_request.elapsed().as_secs() < 60 {
// 1 request per minute per IP
return Err((
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({ "error": "Rate limit exceeded. Try again in a minute." })),
));
}
*rate_limits = Instant::now();
}
// 2. Generate secure random polling token
// Generate secure random polling token
let polling_token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(64)