manta_server/server/
auth_middleware.rs

1//! Defensive middleware for the `/api/v1/auth/*` sub-router.
2//!
3//! Two layers, applied in this order:
4//!
5//! 1. `rate_limit` — per-source-IP token-bucket. Drops requests that
6//!    exceed `[server].auth_rate_limit_per_minute` with a 429 response.
7//!    Source IP comes from the connection (after the optional
8//!    `X-Forwarded-For` handling that ConnectInfo gives us). Operators
9//!    are still expected to terminate at a reverse proxy and rate-limit
10//!    there too — this is defence in depth.
11//!
12//! 2. `strip_body_for_logs` — explicit, even though the request-logger
13//!    in `super::log_requests` only logs `method + uri + status` today.
14//!    Treat it as a hard guarantee that credentials submitted to
15//!    `/auth/token` never end up in a log line, regardless of what the
16//!    logger middleware grows into in future.
17
18use std::collections::HashMap;
19use std::net::{IpAddr, SocketAddr};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23use axum::{
24  Json,
25  extract::{ConnectInfo, Request, State},
26  http::StatusCode,
27  middleware::Next,
28  response::{IntoResponse, Response},
29};
30
31use super::ServerState;
32use super::handlers::ErrorResponse;
33
34/// Per-IP state for the token-bucket rate limiter.
35struct WindowState {
36  window_start: Instant,
37  count: u32,
38}
39
40/// In-memory rate-limit table, sized by the number of distinct source IPs
41/// that hit `/auth/*` in the last minute. For typical CLI fleets this is
42/// small; entries older than two windows are pruned on every check.
43#[derive(Default)]
44pub struct AuthRateLimiter {
45  windows: Mutex<HashMap<IpAddr, WindowState>>,
46}
47
48impl AuthRateLimiter {
49  /// Construct a fresh limiter wrapped in an `Arc` so it can be
50  /// shared via Axum's `Extension` layer across handler invocations.
51  pub fn new() -> Arc<Self> {
52    Arc::new(Self::default())
53  }
54
55  /// Returns `true` if `ip` is allowed to make one more request under
56  /// the given `limit` (requests per minute), `false` if it would
57  /// exceed.
58  fn check(&self, ip: IpAddr, limit: u32) -> bool {
59    self.check_at(ip, limit, Instant::now())
60  }
61
62  /// Testable variant of [`check`] with an explicit clock. The split
63  /// lets unit tests exercise the window-reset and pruning logic
64  /// without actually sleeping 60+ seconds.
65  fn check_at(&self, ip: IpAddr, limit: u32, now: Instant) -> bool {
66    let window = Duration::from_secs(60);
67    let mut windows = self.windows.lock().expect("rate limiter mutex poisoned");
68
69    // Opportunistic pruning of stale entries.
70    windows
71      .retain(|_, state| now.duration_since(state.window_start) < window * 2);
72
73    let entry = windows.entry(ip).or_insert(WindowState {
74      window_start: now,
75      count: 0,
76    });
77
78    if now.duration_since(entry.window_start) >= window {
79      entry.window_start = now;
80      entry.count = 0;
81    }
82
83    if entry.count >= limit {
84      return false;
85    }
86    entry.count += 1;
87    true
88  }
89}
90
91/// Per-source-IP rate limit middleware.  Reads
92/// `[server].auth_rate_limit_per_minute` from `ServerState`; when `None`,
93/// the middleware is a no-op (operators rate-limit at the proxy).
94pub async fn rate_limit(
95  State(state): State<Arc<ServerState>>,
96  ConnectInfo(peer): ConnectInfo<SocketAddr>,
97  limiter: axum::extract::Extension<Arc<AuthRateLimiter>>,
98  request: Request,
99  next: Next,
100) -> Response {
101  let Some(limit) = state.auth_rate_limit_per_minute else {
102    return next.run(request).await;
103  };
104  if !limiter.check(peer.ip(), limit) {
105    tracing::warn!(
106      "auth: rate limit exceeded for source {} (limit={}/min)",
107      peer.ip(),
108      limit
109    );
110    return (
111      StatusCode::TOO_MANY_REQUESTS,
112      Json(ErrorResponse {
113        error: "rate limit exceeded".to_string(),
114      }),
115    )
116      .into_response();
117  }
118  next.run(request).await
119}
120
121/// Belt-and-braces: ensure no `/auth/*` request body ever reaches a
122/// logger.  The runtime cost is one logger-scoped `tracing` span with
123/// the body field redacted; the body itself is forwarded to the handler
124/// untouched.
125pub async fn strip_body_for_logs(request: Request, next: Next) -> Response {
126  let span = tracing::info_span!("auth_request", body = "<redacted>");
127  let _enter = span.enter();
128  next.run(request).await
129}
130
131#[cfg(test)]
132mod tests;