Skip to main content

fraiseql_server/middleware/rate_limit/
config.rs

1//! Rate limit configuration types.
2
3use serde::{Deserialize, Serialize};
4
5/// Minimal mirror of the `[security.rate_limiting]` TOML section, deserialized
6/// from the compiled schema's `security.rate_limiting` JSON key.
7#[derive(Debug, Clone, Deserialize, Default)]
8#[serde(default)]
9pub struct RateLimitingSecurityConfig {
10    /// Enable rate limiting.
11    pub enabled: bool,
12    /// Global request rate cap (requests per second, per IP).
13    pub requests_per_second: u32,
14    /// Burst allowance above the steady-state rate.
15    pub burst_size: u32,
16    /// Auth initiation endpoint — max requests per window.
17    pub auth_start_max_requests: u32,
18    /// Auth initiation window in seconds.
19    pub auth_start_window_secs: u64,
20    /// OAuth callback endpoint — max requests per window.
21    pub auth_callback_max_requests: u32,
22    /// OAuth callback window in seconds.
23    pub auth_callback_window_secs: u64,
24    /// Token refresh endpoint — max requests per window.
25    pub auth_refresh_max_requests: u32,
26    /// Token refresh window in seconds.
27    pub auth_refresh_window_secs: u64,
28    /// Per-authenticated-user request rate in requests/second.
29    /// Defaults to 10× `requests_per_second` if not set.
30    #[serde(default)]
31    pub requests_per_second_per_user: Option<u32>,
32    /// Redis URL for distributed rate limiting (not yet implemented).
33    pub redis_url: Option<String>,
34    /// Trust `X-Real-IP` / `X-Forwarded-For` headers for the client IP.
35    ///
36    /// Enable only when FraiseQL is deployed behind a trusted reverse proxy
37    /// (e.g. nginx, Cloudflare, AWS ALB) that sets these headers.  Enabling
38    /// without a trusted proxy allows clients to spoof their IP address.
39    #[serde(default)]
40    pub trust_proxy_headers: bool,
41
42    /// CIDR ranges trusted as proxy IPs (e.g. `["10.0.0.0/8", "172.16.0.0/12"]`).
43    ///
44    /// When set and `trust_proxy_headers = true`, X-Forwarded-For is only honoured
45    /// when the direct connection IP falls within one of these CIDR ranges.
46    /// Requests arriving from outside these ranges use the connection IP directly,
47    /// preventing clients from spoofing their address by setting X-Forwarded-For.
48    ///
49    /// When `None` and `trust_proxy_headers = true`, all proxy IPs are trusted
50    /// (less secure — a startup warning is emitted).
51    #[serde(default)]
52    pub trusted_proxy_cidrs: Option<Vec<String>>,
53}
54
55/// Rate limiting configuration (token-bucket algorithm).
56///
57/// Enforces request-per-second limits per IP/user across all GraphQL
58/// operations. This is the canonical rate limiter for request throttling.
59///
60/// Distinct from `fraiseql_auth::AuthRateLimitConfig`, which uses a
61/// sliding-window algorithm for auth endpoint brute-force protection.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RateLimitConfig {
64    /// Enable rate limiting
65    pub enabled: bool,
66
67    /// Requests per second per IP
68    pub rps_per_ip: u32,
69
70    /// Requests per second per user (if authenticated)
71    pub rps_per_user: u32,
72
73    /// Burst capacity (maximum tokens to accumulate)
74    pub burst_size: u32,
75
76    /// Cleanup interval in seconds (remove stale entries)
77    pub cleanup_interval_secs: u64,
78
79    /// Trust `X-Real-IP` / `X-Forwarded-For` headers for client IP extraction.
80    ///
81    /// Must only be enabled when behind a trusted reverse proxy.
82    pub trust_proxy_headers: bool,
83
84    /// Parsed CIDR ranges trusted as proxy IPs.
85    ///
86    /// When non-empty, X-Forwarded-For is only trusted if the direct connection IP
87    /// falls within one of these ranges.  An empty `Vec` with `trust_proxy_headers = true`
88    /// means all direct IPs are treated as trusted proxies (less secure).
89    pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
90
91    /// Maximum number of unique IP/user buckets to hold in memory at once.
92    ///
93    /// When any of the three tracking maps (`ip_buckets`, `user_buckets`,
94    /// `path_ip_buckets`) reaches this limit, requests arriving from a
95    /// previously-unseen key are **denied** until stale entries are evicted by
96    /// the background cleanup task.  This prevents unbounded memory growth
97    /// under a flood of spoofed or unique source IPs.
98    ///
99    /// Defaults to `100_000`.  At ~200 bytes per bucket, this cap allows up to
100    /// ~20 `MiB` of tracking state per map before enforcement kicks in.
101    #[serde(default = "default_max_buckets")]
102    pub max_buckets: usize,
103}
104
105const fn default_max_buckets() -> usize {
106    100_000
107}
108
109impl Default for RateLimitConfig {
110    fn default() -> Self {
111        Self {
112            enabled:               true,
113            rps_per_ip:            100,  // 100 req/sec per IP
114            rps_per_user:          1000, // 1000 req/sec per user
115            burst_size:            500,  // Allow bursts up to 500 requests
116            cleanup_interval_secs: 300,  // Clean up every 5 minutes
117            trust_proxy_headers:   false,
118            trusted_proxy_cidrs:   Vec::new(),
119            max_buckets:           100_000,
120        }
121    }
122}
123
124impl RateLimitConfig {
125    /// Build from the `[security.rate_limiting]` config embedded in the compiled schema.
126    ///
127    /// Maps `requests_per_second` → `rps_per_ip` and `burst_size` directly.
128    /// `rps_per_user` uses the explicit `requests_per_second_per_user` value when set,
129    /// or defaults to 10× `requests_per_second`.
130    ///
131    /// The default 10× multiplier reflects that authenticated users are identifiable
132    /// (abuse is traceable) and include service accounts with higher call rates.
133    /// Operators can override with `requests_per_second_per_user` in `fraiseql.toml`.
134    #[must_use]
135    pub fn from_security_config(sec: &RateLimitingSecurityConfig) -> Self {
136        let trusted_proxy_cidrs = sec
137            .trusted_proxy_cidrs
138            .as_deref()
139            .unwrap_or(&[])
140            .iter()
141            .filter_map(|s| {
142                s.parse::<ipnet::IpNet>()
143                    .map_err(|e| {
144                        tracing::warn!(cidr = %s, error = %e, "Invalid trusted_proxy_cidr — skipping");
145                    })
146                    .ok()
147            })
148            .collect();
149
150        Self {
151            enabled: sec.enabled,
152            rps_per_ip: sec.requests_per_second,
153            rps_per_user: sec
154                .requests_per_second_per_user
155                .unwrap_or_else(|| sec.requests_per_second.saturating_mul(10)),
156            burst_size: sec.burst_size,
157            cleanup_interval_secs: 300,
158            trust_proxy_headers: sec.trust_proxy_headers,
159            trusted_proxy_cidrs,
160            max_buckets: default_max_buckets(),
161        }
162    }
163}
164
165/// Result returned by all `check_*` rate-limit methods.
166///
167/// Carries the allow/deny decision, the approximate remaining token count
168/// (used for the `X-RateLimit-Remaining` response header), and the
169/// recommended `Retry-After` interval in seconds (0 when the request was
170/// allowed).
171#[derive(Debug, Clone)]
172pub struct CheckResult {
173    /// Whether the request should be allowed.
174    pub allowed:          bool,
175    /// Tokens remaining in the bucket after this request (≥ 0).
176    pub remaining:        f64,
177    /// Seconds the client should wait before retrying (0 when allowed).
178    pub retry_after_secs: u32,
179}
180
181impl CheckResult {
182    pub(super) const fn allow(remaining: f64) -> Self {
183        Self {
184            allowed: true,
185            remaining,
186            retry_after_secs: 0,
187        }
188    }
189
190    pub(super) const fn deny(retry_after_secs: u32) -> Self {
191        Self {
192            allowed: false,
193            remaining: 0.0,
194            retry_after_secs,
195        }
196    }
197}