| 2 |
2 |
|
|
| 3 |
3 |
|
use std::collections::{HashMap, HashSet};
|
| 4 |
4 |
|
use std::sync::Arc;
|
| 5 |
|
- |
use std::sync::atomic::{AtomicU64, Ordering};
|
| 6 |
5 |
|
|
| 7 |
6 |
|
use axum::extract::{Path, Request, State as AxumState};
|
| 8 |
7 |
|
use axum::http::StatusCode;
|
| 18 |
17 |
|
use crate::peer::SharedMeshState;
|
| 19 |
18 |
|
use crate::types::{HealthSnapshot, LatencyBucket, LatencyStats, TestStaleness};
|
| 20 |
19 |
|
|
| 21 |
|
- |
/// Fixed-window rate limiter: allows `max_per_window` requests per `window_duration`.
|
|
20 |
+ |
/// Fixed-window rate limiter, keyed per client IP.
|
|
21 |
+ |
///
|
|
22 |
+ |
/// Previously a single global counter shared across all clients (fuzz-2026-07-06
|
|
23 |
+ |
/// SERIOUS #5): one noisy source burned the whole 60/min budget for everyone.
|
|
24 |
+ |
/// Combined with the layer reorder (auth now runs before this), an unauthenticated
|
|
25 |
+ |
/// attacker can't reach the limiter at all, and an authenticated client's bucket
|
|
26 |
+ |
/// is isolated to its own IP.
|
| 22 |
27 |
|
#[derive(Clone)]
|
| 23 |
|
- |
pub struct RateLimiter {
|
| 24 |
|
- |
count: Arc<AtomicU64>,
|
| 25 |
|
- |
window_start: Arc<std::sync::Mutex<std::time::Instant>>,
|
|
28 |
+ |
pub struct PerIpRateLimiter {
|
|
29 |
+ |
windows: Arc<std::sync::Mutex<HashMap<std::net::IpAddr, (std::time::Instant, u64)>>>,
|
| 26 |
30 |
|
max_per_window: u64,
|
| 27 |
31 |
|
window_duration: std::time::Duration,
|
| 28 |
32 |
|
}
|
| 29 |
33 |
|
|
| 30 |
|
- |
impl RateLimiter {
|
|
34 |
+ |
impl PerIpRateLimiter {
|
| 31 |
35 |
|
pub fn new(max_per_window: u64, window_duration: std::time::Duration) -> Self {
|
| 32 |
36 |
|
Self {
|
| 33 |
|
- |
count: Arc::new(AtomicU64::new(0)),
|
| 34 |
|
- |
window_start: Arc::new(std::sync::Mutex::new(std::time::Instant::now())),
|
|
37 |
+ |
windows: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
| 35 |
38 |
|
max_per_window,
|
| 36 |
39 |
|
window_duration,
|
| 37 |
40 |
|
}
|
| 38 |
41 |
|
}
|
| 39 |
42 |
|
|
| 40 |
|
- |
pub fn try_acquire(&self) -> bool {
|
| 41 |
|
- |
let mut start = self.window_start.lock().unwrap();
|
|
43 |
+ |
pub fn try_acquire(&self, ip: std::net::IpAddr) -> bool {
|
| 42 |
44 |
|
let now = std::time::Instant::now();
|
|
45 |
+ |
let mut windows = self.windows.lock().unwrap();
|
|
46 |
+ |
// Opportunistically evict stale buckets so the map can't grow unbounded
|
|
47 |
+ |
// from transient/spoofed source IPs.
|
|
48 |
+ |
windows.retain(|_, (start, _)| now.duration_since(*start) <= self.window_duration);
|
|
49 |
+ |
let (start, count) = windows.entry(ip).or_insert((now, 0));
|
| 43 |
50 |
|
if now.duration_since(*start) > self.window_duration {
|
| 44 |
51 |
|
*start = now;
|
| 45 |
|
- |
self.count.store(1, Ordering::Release);
|
|
52 |
+ |
*count = 1;
|
| 46 |
53 |
|
true
|
| 47 |
54 |
|
} else {
|
| 48 |
|
- |
let prev = self.count.fetch_add(1, Ordering::Acquire);
|
| 49 |
|
- |
prev < self.max_per_window
|
|
55 |
+ |
*count += 1;
|
|
56 |
+ |
*count <= self.max_per_window
|
| 50 |
57 |
|
}
|
| 51 |
58 |
|
}
|
| 52 |
59 |
|
}
|
| 53 |
60 |
|
|
|
61 |
+ |
/// Mint a fresh random dashboard session secret (128 bits, hex). Ephemeral: it
|
|
62 |
+ |
/// lives only for the process, so a restart invalidates any leaked cookie.
|
|
63 |
+ |
pub(crate) fn mint_dashboard_token() -> String {
|
|
64 |
+ |
uuid::Uuid::new_v4().simple().to_string()
|
|
65 |
+ |
}
|
|
66 |
+ |
|
| 54 |
67 |
|
/// Shared state for the API server.
|
| 55 |
68 |
|
#[derive(Clone)]
|
| 56 |
69 |
|
pub struct ApiState {
|
| 57 |
70 |
|
pub pool: sqlx::SqlitePool,
|
| 58 |
71 |
|
pub config: Arc<Config>,
|
| 59 |
72 |
|
pub mesh: Option<SharedMeshState>,
|
| 60 |
|
- |
pub rate_limiter: RateLimiter,
|
|
73 |
+ |
pub rate_limiter: PerIpRateLimiter,
|
|
74 |
+ |
/// Ephemeral, per-process dashboard session secret. `Some` only when the
|
|
75 |
+ |
/// dashboard is enabled. Handed to the browser as an httpOnly cookie (never
|
|
76 |
+ |
/// embedded in page JS, never the long-lived `api_token`), and accepted by
|
|
77 |
+ |
/// [`require_bearer_token`] for same-origin dashboard `/api/*` calls.
|
|
78 |
+ |
pub dashboard_token: Option<Arc<str>>,
|
| 61 |
79 |
|
}
|
| 62 |
80 |
|
|
| 63 |
81 |
|
/// Rate limiting middleware. Returns 429 if the request rate exceeds the limit.
|
|
82 |
+ |
/// Runs *inside* the auth layer, so only authenticated requests are counted and
|
|
83 |
+ |
/// each client IP has its own bucket.
|
| 64 |
84 |
|
async fn rate_limit(
|
| 65 |
85 |
|
AxumState(state): AxumState<ApiState>,
|
|
86 |
+ |
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
|
| 66 |
87 |
|
req: Request,
|
| 67 |
88 |
|
next: Next,
|
| 68 |
89 |
|
) -> impl IntoResponse {
|
| 69 |
|
- |
if state.rate_limiter.try_acquire() {
|
|
90 |
+ |
if state.rate_limiter.try_acquire(peer.ip()) {
|
| 70 |
91 |
|
Ok(next.run(req).await)
|
| 71 |
92 |
|
} else {
|
| 72 |
93 |
|
Err((StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({
|
| 75 |
96 |
|
}
|
| 76 |
97 |
|
}
|
| 77 |
98 |
|
|
|
99 |
+ |
/// Read the `pom_dash` session cookie from a request, if present.
|
|
100 |
+ |
fn dashboard_cookie(req: &Request) -> Option<String> {
|
|
101 |
+ |
let cookies = req.headers().get(axum::http::header::COOKIE)?.to_str().ok()?;
|
|
102 |
+ |
cookies.split(';').find_map(|kv| {
|
|
103 |
+ |
let (k, v) = kv.split_once('=')?;
|
|
104 |
+ |
(k.trim() == "pom_dash").then(|| v.trim().to_string())
|
|
105 |
+ |
})
|
|
106 |
+ |
}
|
|
107 |
+ |
|
| 78 |
108 |
|
/// Bearer token authentication middleware.
|
| 79 |
109 |
|
/// If `api_token` is configured, requires `Authorization: Bearer <token>` on every request.
|
| 80 |
110 |
|
/// If no token is configured, all requests pass through.
|
| 88 |
118 |
|
return Ok(next.run(req).await);
|
| 89 |
119 |
|
};
|
| 90 |
120 |
|
|
|
121 |
+ |
use subtle::ConstantTimeEq;
|
|
122 |
+ |
|
|
123 |
+ |
// Accept the same-origin dashboard session cookie (httpOnly, ephemeral) as an
|
|
124 |
+ |
// alternative to the bearer token, so the dashboard never has to embed the
|
|
125 |
+ |
// long-lived api_token in page JS (fuzz-2026-07-06 SERIOUS #4).
|
|
126 |
+ |
if let (Some(dash), Some(cookie)) = (state.dashboard_token.as_deref(), dashboard_cookie(&req))
|
|
127 |
+ |
&& cookie.as_bytes().ct_eq(dash.as_bytes()).into()
|
|
128 |
+ |
{
|
|
129 |
+ |
return Ok(next.run(req).await);
|
|
130 |
+ |
}
|
|
131 |
+ |
|
| 91 |
132 |
|
let auth_header = req.headers().get("authorization").and_then(|v| v.to_str().ok());
|
| 92 |
133 |
|
match auth_header {
|
| 93 |
134 |
|
Some(header) if header.starts_with("Bearer ") => {
|
| 94 |
135 |
|
let token = &header[7..];
|
| 95 |
136 |
|
// Constant-time comparison to prevent timing side-channels
|
| 96 |
|
- |
use subtle::ConstantTimeEq;
|
| 97 |
137 |
|
if token.as_bytes().ct_eq(expected.as_bytes()).into() {
|
| 98 |
138 |
|
Ok(next.run(req).await)
|
| 99 |
139 |
|
} else {
|
| 119 |
159 |
|
|
| 120 |
160 |
|
/// Build the axum router for the PoM API.
|
| 121 |
161 |
|
pub fn router(pool: sqlx::SqlitePool, config: Config, mesh: Option<SharedMeshState>) -> Router {
|
|
162 |
+ |
// Ephemeral dashboard session secret, minted once per process when the
|
|
163 |
+ |
// dashboard is enabled — never the api_token, never embedded in page JS.
|
|
164 |
+ |
let dashboard_token: Option<Arc<str>> = config
|
|
165 |
+ |
.serve
|
|
166 |
+ |
.dashboard
|
|
167 |
+ |
.then(|| Arc::from(crate::api::mint_dashboard_token().as_str()));
|
|
168 |
+ |
|
| 122 |
169 |
|
let state = ApiState {
|
| 123 |
170 |
|
pool,
|
| 124 |
171 |
|
config: Arc::new(config),
|
| 125 |
172 |
|
mesh,
|
| 126 |
|
- |
rate_limiter: RateLimiter::new(60, std::time::Duration::from_secs(60)),
|
|
173 |
+ |
rate_limiter: PerIpRateLimiter::new(60, std::time::Duration::from_secs(60)),
|
|
174 |
+ |
dashboard_token,
|
| 127 |
175 |
|
};
|
| 128 |
176 |
|
|
| 129 |
|
- |
// Authenticated routes (behind bearer token + rate limit)
|
|
177 |
+ |
// Authenticated routes. Order matters: `.layer` wraps outward, so listing
|
|
178 |
+ |
// `require_bearer_token` LAST makes it the OUTERMOST layer — auth runs before
|
|
179 |
+ |
// rate_limit, so an unauthenticated request is rejected before it can consume
|
|
180 |
+ |
// any rate-limit budget (fuzz-2026-07-06 SERIOUS #5).
|
| 130 |
181 |
|
let authenticated = Router::new()
|
| 131 |
182 |
|
.route("/api/status", get(status_all))
|
| 132 |
183 |
|
.route("/api/status/{target}", get(status_target))
|
| 134 |
185 |
|
.route("/api/peer/info", get(peer_info))
|
| 135 |
186 |
|
.route("/api/peer/status", get(peer_status))
|
| 136 |
187 |
|
.route("/api/mesh", get(mesh_view))
|
| 137 |
|
- |
.layer(middleware::from_fn_with_state(state.clone(), require_bearer_token))
|
| 138 |
|
- |
.layer(middleware::from_fn_with_state(state.clone(), rate_limit));
|
|
188 |
+ |
.layer(middleware::from_fn_with_state(state.clone(), rate_limit))
|
|
189 |
+ |
.layer(middleware::from_fn_with_state(state.clone(), require_bearer_token));
|
| 139 |
190 |
|
|
| 140 |
191 |
|
// Public routes (no auth, no rate limit)
|
| 141 |
192 |
|
let public = Router::new()
|
| 652 |
703 |
|
let pool = crate::db::connect_in_memory().await.unwrap();
|
| 653 |
704 |
|
let app = router(pool, test_config(None), None);
|
| 654 |
705 |
|
|
| 655 |
|
- |
let req = HttpRequest::builder()
|
| 656 |
|
- |
.uri("/api/status")
|
| 657 |
|
- |
.body(Body::empty())
|
| 658 |
|
- |
.unwrap();
|
| 659 |
|
- |
let resp = app.oneshot(req).await.unwrap();
|
|
706 |
+ |
let resp = app.oneshot(with_connect_info("/api/status", None)).await.unwrap();
|
| 660 |
707 |
|
assert_eq!(resp.status(), StatusCode::OK);
|
| 661 |
708 |
|
}
|
| 662 |
709 |
|
|
| 665 |
712 |
|
let pool = crate::db::connect_in_memory().await.unwrap();
|
| 666 |
713 |
|
let app = router(pool, test_config(Some("secret123")), None);
|
| 667 |
714 |
|
|
| 668 |
|
- |
let req = HttpRequest::builder()
|
|
715 |
+ |
let resp = app
|
|
716 |
+ |
.oneshot(with_connect_info("/api/status", Some("Bearer secret123")))
|
|
717 |
+ |
.await
|
|
718 |
+ |
.unwrap();
|
|
719 |
+ |
assert_eq!(resp.status(), StatusCode::OK);
|
|
720 |
+ |
}
|
|
721 |
+ |
|
|
722 |
+ |
#[tokio::test]
|
|
723 |
+ |
async fn dashboard_uses_cookie_not_embedded_token() {
|
|
724 |
+ |
// SERIOUS #4: GET / must NOT ship the api_token in the page, and must set
|
|
725 |
+ |
// an httpOnly session cookie that authenticates the dashboard's /api/* calls.
|
|
726 |
+ |
let pool = crate::db::connect_in_memory().await.unwrap();
|
|
727 |
+ |
let mut config = test_config(Some("supersecret-token"));
|
|
728 |
+ |
config.serve.dashboard = true;
|
|
729 |
+ |
let app = router(pool, config, None);
|
|
730 |
+ |
|
|
731 |
+ |
let req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
|
|
732 |
+ |
let resp = app.clone().oneshot(req).await.unwrap();
|
|
733 |
+ |
assert_eq!(resp.status(), StatusCode::OK);
|
|
734 |
+ |
|
|
735 |
+ |
let cookie_hdr = resp
|
|
736 |
+ |
.headers()
|
|
737 |
+ |
.get(axum::http::header::SET_COOKIE)
|
|
738 |
+ |
.expect("dashboard must set a session cookie")
|
|
739 |
+ |
.to_str()
|
|
740 |
+ |
.unwrap()
|
|
741 |
+ |
.to_string();
|
|
742 |
+ |
assert!(cookie_hdr.contains("pom_dash="));
|
|
743 |
+ |
assert!(cookie_hdr.contains("HttpOnly"));
|
|
744 |
+ |
assert!(!cookie_hdr.contains("supersecret-token"), "cookie must not be the api_token");
|
|
745 |
+ |
|
|
746 |
+ |
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
747 |
+ |
let html = String::from_utf8_lossy(&body);
|
|
748 |
+ |
assert!(!html.contains("supersecret-token"), "the api_token must never appear in served HTML");
|
|
749 |
+ |
|
|
750 |
+ |
// The issued cookie authenticates an /api/* call without any bearer token.
|
|
751 |
+ |
let dash = cookie_hdr
|
|
752 |
+ |
.split(';')
|
|
753 |
+ |
.next()
|
|
754 |
+ |
.unwrap()
|
|
755 |
+ |
.trim()
|
|
756 |
+ |
.to_string(); // "pom_dash=<value>"
|
|
757 |
+ |
let mut api_req = HttpRequest::builder()
|
| 669 |
758 |
|
.uri("/api/status")
|
| 670 |
|
- |
.header("authorization", "Bearer secret123")
|
|
759 |
+ |
.header(axum::http::header::COOKIE, dash)
|
| 671 |
760 |
|
.body(Body::empty())
|
| 672 |
761 |
|
.unwrap();
|
| 673 |
|
- |
let resp = app.oneshot(req).await.unwrap();
|
| 674 |
|
- |
assert_eq!(resp.status(), StatusCode::OK);
|
|
762 |
+ |
api_req.extensions_mut().insert(axum::extract::ConnectInfo(
|
|
763 |
+ |
std::net::SocketAddr::from(([127, 0, 0, 1], 40001)),
|
|
764 |
+ |
));
|
|
765 |
+ |
let api_resp = app.oneshot(api_req).await.unwrap();
|
|
766 |
+ |
assert_eq!(api_resp.status(), StatusCode::OK, "dashboard cookie must authenticate /api/*");
|
| 675 |
767 |
|
}
|
| 676 |
768 |
|
|
| 677 |
769 |
|
#[tokio::test]
|
| 715 |
807 |
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
| 716 |
808 |
|
}
|
| 717 |
809 |
|
|
|
810 |
+ |
fn ip(n: u8) -> std::net::IpAddr {
|
|
811 |
+ |
std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, n))
|
|
812 |
+ |
}
|
|
813 |
+ |
|
|
814 |
+ |
/// Build a GET request carrying a `ConnectInfo<SocketAddr>` extension, which
|
|
815 |
+ |
/// the real server injects via `into_make_service_with_connect_info` but
|
|
816 |
+ |
/// `oneshot` does not — the rate-limit layer extracts it.
|
|
817 |
+ |
fn with_connect_info(uri: &str, bearer: Option<&str>) -> HttpRequest<Body> {
|
|
818 |
+ |
let mut b = HttpRequest::builder().uri(uri);
|
|
819 |
+ |
if let Some(h) = bearer {
|
|
820 |
+ |
b = b.header("authorization", h);
|
|
821 |
+ |
}
|
|
822 |
+ |
let mut req = b.body(Body::empty()).unwrap();
|
|
823 |
+ |
req.extensions_mut().insert(axum::extract::ConnectInfo(
|
|
824 |
+ |
std::net::SocketAddr::from(([127, 0, 0, 1], 40000)),
|
|
825 |
+ |
));
|
|
826 |
+ |
req
|
|
827 |
+ |
}
|
|
828 |
+ |
|
| 718 |
829 |
|
#[test]
|
| 719 |
830 |
|
fn rate_limiter_allows_within_limit() {
|
| 720 |
|
- |
let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60));
|
| 721 |
|
- |
assert!(limiter.try_acquire());
|
| 722 |
|
- |
assert!(limiter.try_acquire());
|
| 723 |
|
- |
assert!(limiter.try_acquire());
|
|
831 |
+ |
let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_secs(60));
|
|
832 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
833 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
834 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
| 724 |
835 |
|
}
|
| 725 |
836 |
|
|
| 726 |
837 |
|
#[test]
|
| 727 |
838 |
|
fn rate_limiter_blocks_over_limit() {
|
| 728 |
|
- |
let limiter = RateLimiter::new(2, std::time::Duration::from_secs(60));
|
| 729 |
|
- |
assert!(limiter.try_acquire());
|
| 730 |
|
- |
assert!(limiter.try_acquire());
|
| 731 |
|
- |
assert!(!limiter.try_acquire());
|
|
839 |
+ |
let limiter = PerIpRateLimiter::new(2, std::time::Duration::from_secs(60));
|
|
840 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
841 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
842 |
+ |
assert!(!limiter.try_acquire(ip(1)));
|
|
843 |
+ |
}
|
|
844 |
+ |
|
|
845 |
+ |
#[test]
|
|
846 |
+ |
fn rate_limiter_isolates_clients_by_ip() {
|
|
847 |
+ |
// SERIOUS #5: one client exhausting its bucket must not affect another.
|
|
848 |
+ |
let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_secs(60));
|
|
849 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
850 |
+ |
assert!(!limiter.try_acquire(ip(1)), "ip(1) is now over its own limit");
|
|
851 |
+ |
assert!(limiter.try_acquire(ip(2)), "ip(2) has its own fresh bucket");
|
| 732 |
852 |
|
}
|
| 733 |
853 |
|
|
| 734 |
854 |
|
#[tokio::test]
|
| 735 |
855 |
|
async fn rate_limiter_resets_after_window() {
|
| 736 |
|
- |
let limiter = RateLimiter::new(1, std::time::Duration::from_millis(10));
|
| 737 |
|
- |
assert!(limiter.try_acquire());
|
| 738 |
|
- |
assert!(!limiter.try_acquire());
|
|
856 |
+ |
let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_millis(10));
|
|
857 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
|
858 |
+ |
assert!(!limiter.try_acquire(ip(1)));
|
| 739 |
859 |
|
tokio::time::sleep(std::time::Duration::from_millis(15)).await;
|
| 740 |
|
- |
assert!(limiter.try_acquire());
|
| 741 |
|
- |
}
|
| 742 |
|
- |
|
| 743 |
|
- |
#[test]
|
| 744 |
|
- |
fn rate_limiter_counter_starts_at_one() {
|
| 745 |
|
- |
let limiter = RateLimiter::new(1, std::time::Duration::from_millis(0));
|
| 746 |
|
- |
// First call resets window (counter stored as 1), returns true
|
| 747 |
|
- |
assert!(limiter.try_acquire());
|
| 748 |
|
- |
// Window has already expired (0ms), so next call also resets
|
| 749 |
|
- |
assert!(limiter.try_acquire());
|
|
860 |
+ |
assert!(limiter.try_acquire(ip(1)));
|
| 750 |
861 |
|
}
|
| 751 |
862 |
|
}
|