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