Skip to main content

max / makenotwork

pom: stop leaking the API token to the dashboard; rate-limit per IP SERIOUS #4: GET / (dashboard) sat outside the auth router and baked the long-lived api_token into served JS, so anyone who could reach / read it and replayed it against every /api/* route — full auth bypass if listen is non-loopback. The dashboard now authenticates with an ephemeral, per-process httpOnly SameSite=Strict pom_dash cookie (never the api_token, never in page JS); require_bearer_token accepts it for same-origin calls. SERIOUS #5: the rate limiter was a single global 60/min counter running OUTSIDE auth, so a token-less attacker burned the whole budget and 429'd the real consumer. Reorder so auth runs before rate-limit (unauthenticated requests never reach the counter) and key the limiter per client IP via ConnectInfo, so one noisy client can't starve others. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 15:58 UTC
Commit: 51172373b55fb94f820551db774b07b22ffd4fbe
Parent: 97d708f
4 files changed, +221 insertions, -150 deletions
M pom/src/api.rs +161 -50
@@ -2,7 +2,6 @@
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,55 +17,77 @@ use crate::db;
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,6 +96,15 @@ async fn rate_limit(
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,12 +118,22 @@ async fn require_bearer_token(
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,14 +159,25 @@ async fn self_health() -> impl IntoResponse {
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,8 +185,8 @@ pub fn router(pool: sqlx::SqlitePool, config: Config, mesh: Option<SharedMeshSta
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,11 +703,7 @@ mod tests {
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,13 +712,58 @@ mod tests {
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,37 +807,56 @@ mod tests {
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 }
@@ -91,7 +91,10 @@ pub(crate) async fn cmd_serve(
91 91 info!("API server listening on {listen_addr}");
92 92 let api_cancel = token.clone();
93 93 handles.push(tokio::spawn(async move {
94 - if let Err(e) = axum::serve(api_listener, api_app)
94 + if let Err(e) = axum::serve(
95 + api_listener,
96 + api_app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
97 + )
95 98 .with_graceful_shutdown(async move { api_cancel.cancelled().await })
96 99 .await
97 100 {
@@ -1,40 +1,38 @@
1 1 //! Optional HTML dashboard served at `GET /`.
2 2
3 3 use axum::extract::State as AxumState;
4 + use axum::http::header::SET_COOKIE;
4 5 use axum::response::{Html, IntoResponse};
5 6
6 7 use crate::api::ApiState;
7 8
8 9 /// Handler for `GET /` — returns the dashboard HTML page.
10 + ///
11 + /// Instead of baking the long-lived `api_token` into the served JS (where anyone
12 + /// who can reach `/` reads it and replays it against `/api/*` — fuzz-2026-07-06
13 + /// SERIOUS #4), hand the browser an ephemeral, per-process dashboard secret as an
14 + /// httpOnly, SameSite=Strict cookie. The page JS holds no token and calls the API
15 + /// with `credentials: 'same-origin'`; `require_bearer_token` accepts the cookie.
9 16 pub async fn dashboard_handler(AxumState(state): AxumState<ApiState>) -> impl IntoResponse {
10 17 let instance_name = state.config.instance_name();
11 18 let version = env!("CARGO_PKG_VERSION");
12 - let api_token = state.config.serve.api_token.as_deref().unwrap_or("");
13 19 let has_mesh = state.mesh.is_some();
14 - Html(render_dashboard(&instance_name, version, api_token, has_mesh))
15 - }
16 -
17 - /// Escape a string for safe embedding in a JS string literal (double-quoted).
18 - pub(crate) fn escape_js(s: &str) -> String {
19 - let mut out = String::with_capacity(s.len());
20 - for ch in s.chars() {
21 - match ch {
22 - '\\' => out.push_str("\\\\"),
23 - '"' => out.push_str("\\\""),
24 - '\n' => out.push_str("\\n"),
25 - '\r' => out.push_str("\\r"),
26 - '<' => out.push_str("\\x3c"),
27 - '\0' => out.push_str("\\0"),
28 - _ => out.push(ch),
20 + let html = Html(render_dashboard(&instance_name, version, has_mesh));
21 +
22 + match state.dashboard_token.as_deref() {
23 + Some(token) => {
24 + // httpOnly (unreadable by JS/XSS), SameSite=Strict (no cross-site
25 + // send), Path=/ so it accompanies the /api/* fetches.
26 + let cookie = format!("pom_dash={token}; HttpOnly; SameSite=Strict; Path=/");
27 + ([(SET_COOKIE, cookie)], html).into_response()
29 28 }
29 + None => html.into_response(),
30 30 }
31 - out
32 31 }
33 32
34 - fn render_dashboard(instance_name: &str, version: &str, api_token: &str, has_mesh: bool) -> String {
33 + fn render_dashboard(instance_name: &str, version: &str, has_mesh: bool) -> String {
35 34 let js = format!(
36 - "const API_TOKEN = \"{token}\";\nconst HAS_MESH = {has_mesh};\n{JS}",
37 - token = escape_js(api_token),
35 + "const HAS_MESH = {has_mesh};\n{JS}",
38 36 has_mesh = has_mesh,
39 37 JS = JS,
40 38 );
@@ -178,11 +176,10 @@ const JS: &str = r##"
178 176 let countdown = 30;
179 177 let timer = null;
180 178
181 - function headers() {
182 - const h = {};
183 - if (API_TOKEN) h['Authorization'] = 'Bearer ' + API_TOKEN;
184 - return h;
185 - }
179 + // The dashboard authenticates to /api/* with the httpOnly pom_dash session
180 + // cookie (set by GET /), sent automatically by `credentials: 'same-origin'`.
181 + // No token is ever exposed to page JS.
182 + const FETCH_OPTS = { credentials: 'same-origin' };
186 183
187 184 function dotClass(status) {
188 185 if (!status) return 'dot-unknown';
@@ -366,7 +363,7 @@ function renderMesh(data) {
366 363
367 364 async function refresh() {
368 365 try {
369 - const resp = await fetch('/api/status', { headers: headers() });
366 + const resp = await fetch('/api/status', FETCH_OPTS);
370 367 if (!resp.ok) return;
371 368 const data = await resp.json();
372 369 const targets = data.targets || {};
@@ -398,7 +395,7 @@ async function refresh() {
398 395 // Mesh
399 396 if (HAS_MESH) {
400 397 try {
401 - const mr = await fetch('/api/mesh', { headers: headers() });
398 + const mr = await fetch('/api/mesh', FETCH_OPTS);
402 399 if (mr.ok) {
403 400 const md = await mr.json();
404 401 document.getElementById('mesh-section').innerHTML = renderMesh(md);
@@ -419,53 +416,3 @@ function tick() {
419 416 refresh();
420 417 timer = setInterval(tick, 1000);
421 418 "##;
422 -
423 - #[cfg(test)]
424 - mod tests {
425 - use super::*;
426 -
427 - #[test]
428 - fn escape_js_backslash() {
429 - assert_eq!(escape_js(r"a\b"), r"a\\b");
430 - }
431 -
432 - #[test]
433 - fn escape_js_double_quote() {
434 - assert_eq!(escape_js(r#"a"b"#), r#"a\"b"#);
435 - }
436 -
437 - #[test]
438 - fn escape_js_both() {
439 - assert_eq!(escape_js(r#"a\"b"#), r#"a\\\"b"#);
440 - }
441 -
442 - #[test]
443 - fn escape_js_clean_string() {
444 - assert_eq!(escape_js("hello"), "hello");
445 - }
446 -
447 - #[test]
448 - fn escape_js_empty() {
449 - assert_eq!(escape_js(""), "");
450 - }
451 -
452 - #[test]
453 - fn escape_js_newline() {
454 - assert_eq!(escape_js("a\nb"), "a\\nb");
455 - }
456 -
457 - #[test]
458 - fn escape_js_carriage_return() {
459 - assert_eq!(escape_js("a\rb"), "a\\rb");
460 - }
461 -
462 - #[test]
463 - fn escape_js_script_close_tag() {
464 - assert_eq!(escape_js("</script>"), "\\x3c/script>");
465 - }
466 -
467 - #[test]
468 - fn escape_js_null() {
469 - assert_eq!(escape_js("a\0b"), "a\\0b");
470 - }
471 - }
@@ -260,13 +260,23 @@ url = "https://makenot.work/health"
260 260 .unwrap()
261 261 }
262 262
263 - /// GET a path and return (status_code, body_string) — for HTML responses.
264 - async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
265 - let req = axum::http::Request::builder()
263 + /// Build a GET request carrying a `ConnectInfo<SocketAddr>` extension — the real
264 + /// server injects it via `into_make_service_with_connect_info`, but `oneshot`
265 + /// does not, and the per-IP rate-limit layer extracts it.
266 + fn get_req(path: &str) -> axum::http::Request<Body> {
267 + let mut req = axum::http::Request::builder()
266 268 .uri(path)
267 269 .body(Body::empty())
268 270 .unwrap();
269 - let resp = app.clone().oneshot(req).await.unwrap();
271 + req.extensions_mut().insert(axum::extract::ConnectInfo(
272 + std::net::SocketAddr::from(([127, 0, 0, 1], 41000)),
273 + ));
274 + req
275 + }
276 +
277 + /// GET a path and return (status_code, body_string) — for HTML responses.
278 + async fn get_body(app: &axum::Router, path: &str) -> (u16, String) {
279 + let resp = app.clone().oneshot(get_req(path)).await.unwrap();
270 280 let status = resp.status().as_u16();
271 281 let body = resp.into_body().collect().await.unwrap().to_bytes();
272 282 (status, String::from_utf8_lossy(&body).into_owned())
@@ -284,11 +294,7 @@ fn test_mesh() -> pom::peer::SharedMeshState {
284 294 }
285 295
286 296 async fn api_get(app: &axum::Router, path: &str) -> (u16, serde_json::Value) {
287 - let req = axum::http::Request::builder()
288 - .uri(path)
289 - .body(Body::empty())
290 - .unwrap();
291 - let resp = app.clone().oneshot(req).await.unwrap();
297 + let resp = app.clone().oneshot(get_req(path)).await.unwrap();
292 298 let status = resp.status().as_u16();
293 299 let body = resp.into_body().collect().await.unwrap().to_bytes();
294 300 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
@@ -2175,14 +2181,19 @@ async fn api_health_endpoint_no_auth_required() {
2175 2181
2176 2182 #[tokio::test]
2177 2183 async fn api_rate_limit_rejects_excess_requests() {
2178 - use pom::api::RateLimiter;
2184 + use pom::api::PerIpRateLimiter;
2179 2185
2180 - let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60));
2186 + let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1));
2187 + let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_secs(60));
2181 2188
2182 - assert!(limiter.try_acquire()); // 1
2183 - assert!(limiter.try_acquire()); // 2
2184 - assert!(limiter.try_acquire()); // 3
2185 - assert!(!limiter.try_acquire()); // 4 — should be rejected
2189 + assert!(limiter.try_acquire(ip)); // 1
2190 + assert!(limiter.try_acquire(ip)); // 2
2191 + assert!(limiter.try_acquire(ip)); // 3
2192 + assert!(!limiter.try_acquire(ip)); // 4 — should be rejected
2193 +
2194 + // A different client keeps its own budget (SERIOUS #5 isolation).
2195 + let other = std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 2));
2196 + assert!(limiter.try_acquire(other));
2186 2197 }
2187 2198
2188 2199 // --- Peer UUID mismatch state reset test ---
@@ -2815,16 +2826,14 @@ async fn dashboard_disabled_returns_404() {
2815 2826 let config = test_config(); // dashboard defaults to false
2816 2827 let app = pom::api::router(pool, config, None);
2817 2828
2818 - let req = axum::http::Request::builder()
2819 - .uri("/")
2820 - .body(Body::empty())
2821 - .unwrap();
2822 - let resp = app.clone().oneshot(req).await.unwrap();
2829 + let resp = app.clone().oneshot(get_req("/")).await.unwrap();
2823 2830 assert_eq!(resp.status().as_u16(), 404);
2824 2831 }
2825 2832
2826 2833 #[tokio::test]
2827 - async fn dashboard_embeds_api_token() {
2834 + async fn dashboard_does_not_embed_api_token() {
2835 + // SERIOUS #4: the served dashboard page must never contain the api_token —
2836 + // it authenticates via the httpOnly pom_dash cookie instead.
2828 2837 let pool = db::connect_in_memory().await.unwrap();
2829 2838 let mut config = test_config();
2830 2839 config.serve.dashboard = true;
@@ -2833,7 +2842,8 @@ async fn dashboard_embeds_api_token() {
2833 2842
2834 2843 let (status, body) = get_body(&app, "/").await;
2835 2844 assert_eq!(status, 200);
2836 - assert!(body.contains("test-secret-token-42"), "should embed the API token");
2845 + assert!(!body.contains("test-secret-token-42"), "the api_token must not appear in the page");
2846 + assert!(!body.contains("API_TOKEN"), "no token constant should be embedded in the JS");
2837 2847 }
2838 2848
2839 2849 #[tokio::test]