did-hosting-control 0.10.1

DID Hosting Control Plane — Unified management UI and service registry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Per-IP rate limiter for unauthenticated endpoints.
//!
//! `POST /api/auth/challenge` is the only unauthenticated request-shape
//! exposed by the control plane (everything else requires Bearer
//! token, ACL membership, or signed DIDComm). The per-DID and global
//! `PendingChallengeTracker` caps already bound the total session
//! population; this module adds the matching defence at the network
//! layer — an attacker burning through DIDs from a single IP shouldn't
//! be able to issue thousands of challenges per second even though
//! each individual challenge fits within the per-DID/global counters'
//! steady state.
//!
//! # IP-attribution policy
//!
//! Behind a reverse proxy / load balancer / CDN, the TCP peer is the
//! proxy itself, not the real client — every legitimate request would
//! attribute to one IP. The `trusted_proxies` config opts in to
//! parsing `X-Forwarded-For`:
//!
//! - Empty `trusted_proxies` (default): always use the direct TCP peer.
//!   Safe when running on the open internet without a proxy.
//! - Non-empty `trusted_proxies`: walk `X-Forwarded-For` from the
//!   right, skip any entry that's in `trusted_proxies`, and use the
//!   first non-trusted entry as the client IP. If every XFF entry is
//!   trusted (shouldn't happen in practice — there's always a public
//!   client at the head), fall back to the leftmost.
//!
//! Configuring `trusted_proxies` with the wrong value is a foot-gun:
//! trust too much and an attacker can spoof XFF to bypass the limit;
//! trust too little and legitimate proxied requests all hit one
//! limit. Operators should put their actual reverse-proxy IPs there
//! and nothing else.
//!
//! # Algorithm
//!
//! Per-IP fixed-window counter, refreshed every `WINDOW_SECS`. Each
//! `try_consume(ip)` increments and refuses past `MAX_PER_WINDOW`. The
//! window resets lazily on the next call after expiry — no separate
//! sweep task required. Per-IP HashMap entries persist across the
//! whole process lifetime; under sustained novel-IP flood the map
//! grows until eviction kicks in.

use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;

use crate::error::AppError;

/// Maximum challenges per IP per window before rejection. Sized to be
/// generous for legitimate users (a typical browser flow does <10
/// challenge requests in a minute) while bounding an attacker's
/// effective throughput by orders of magnitude.
pub const MAX_PER_WINDOW: u64 = 30;

/// Fixed-window length in seconds. 60s gives the counter a 1 minute
/// reset; combined with `MAX_PER_WINDOW = 30` that's ~0.5 challenges
/// per second steady-state per IP.
pub const WINDOW_SECS: u64 = 60;

/// Hard cap on tracked-IP HashMap size. Once exceeded, the entire
/// map is cleared in a single pass — drastic but the alternative is
/// unbounded memory growth under sustained novel-IP flood. Operators
/// who don't want this behaviour should put the control plane behind
/// a CDN or deploy a real DDoS mitigation.
pub const MAX_TRACKED_IPS: usize = 10_000;

/// The `limiter` this refusal names in its 429 body.
pub const LIMITER_NAME: &str = "auth-challenge-per-ip";

#[derive(Debug, Clone, Copy)]
struct Bucket {
    /// Number of consume attempts in the current window.
    count: u64,
    /// `now_epoch()` of the window start.
    window_start: u64,
}

/// Per-IP rate limiter for the challenge endpoint.
#[derive(Debug, Default)]
pub struct IpRateLimiter {
    buckets: Mutex<HashMap<IpAddr, Bucket>>,
}

impl IpRateLimiter {
    pub fn new() -> Self {
        Self::default()
    }

    /// Attempt to consume one slot for `ip`. Returns `Err` once the
    /// IP has issued `MAX_PER_WINDOW` challenges within the current
    /// `WINDOW_SECS` window.
    pub fn try_consume(&self, ip: IpAddr, now: u64) -> Result<(), AppError> {
        let mut buckets = self
            .buckets
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());

        // Drastic eviction: if the map has grown past the cap, clear
        // it entirely. Documented in the module doc.
        if buckets.len() >= MAX_TRACKED_IPS {
            buckets.clear();
        }

        let entry = buckets.entry(ip).or_insert(Bucket {
            count: 0,
            window_start: now,
        });

        // Reset on window boundary. Lazy — no sweep task.
        if now.saturating_sub(entry.window_start) >= WINDOW_SECS {
            entry.count = 0;
            entry.window_start = now;
        }

        if entry.count >= MAX_PER_WINDOW {
            // The window resets lazily at `window_start + WINDOW_SECS`, so the
            // time left in it is exactly when the next attempt can succeed.
            let retry_after_secs = (entry.window_start + WINDOW_SECS).saturating_sub(now);
            return Err(AppError::RateLimited {
                limiter: LIMITER_NAME,
                message: format!(
                    "IP rate limit exceeded ({MAX_PER_WINDOW} requests per {WINDOW_SECS}s); try again later",
                ),
                retry_after_secs,
            });
        }
        entry.count += 1;
        Ok(())
    }

    #[cfg(test)]
    pub fn count(&self, ip: IpAddr) -> u64 {
        let buckets = self.buckets.lock().unwrap();
        buckets.get(&ip).map(|b| b.count).unwrap_or(0)
    }
}

/// Redemption attempts one source may make per [`WINDOW_SECS`]: an invite's
/// claim code is Argon2-hashed on every attempt, so this bounds both guessing
/// across invites and the hashing a single source can make the service do.
pub const REDEEM_MAX_PER_WINDOW: u64 = 10;

/// The `limiter` a refused redemption names.
pub const REDEEM_LIMITER_NAME: &str = "passkey-redeem-per-source";

/// Per-source limiter for `auth/passkey/enroll/redeem/start`
/// (Conformance item 2: "Rate-limit this task per source as well").
///
/// A source is what the transport can vouch for: the client IP on HTTPS
/// (`ip:…`), the authenticated sender on TSP and DIDComm (`vid:…`). The
/// per-invite wrong-code limit is the real guard on a claim code (and, unlike
/// this limiter, holds across replicas — its counter lives in the shared
/// store behind an atomic increment; see
/// `did_hosting_common::server::passkey::invite`). This one keeps a single
/// source from spraying invites or burning CPU.
///
/// **Per-process, by design.** Unlike the wrong-code counter, this limiter's
/// buckets are an in-memory `HashMap` — nothing here is shared across
/// replicas. A source spread across N replicas behind a load balancer gets
/// effectively `N * REDEEM_MAX_PER_WINDOW` attempts per window, not
/// `REDEEM_MAX_PER_WINDOW`. That's an accepted tradeoff: this limiter sheds
/// load and guessing pressure, it isn't the security boundary (the per-invite
/// wrong-code lockout is), so making it cross-replica would trade a store
/// round trip on every redemption attempt for a guarantee this limiter
/// doesn't need to make.
#[derive(Debug, Default)]
pub struct SourceRateLimiter {
    buckets: Mutex<HashMap<String, Bucket>>,
}

impl SourceRateLimiter {
    pub fn new() -> Self {
        Self::default()
    }

    /// Consume one attempt for `source`, or refuse once it has made
    /// [`REDEEM_MAX_PER_WINDOW`] in the current window.
    pub fn try_consume(&self, source: &str, now: u64) -> Result<(), AppError> {
        let mut buckets = self
            .buckets
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        if buckets.len() >= MAX_TRACKED_IPS {
            buckets.clear();
        }
        let entry = buckets.entry(source.to_string()).or_insert(Bucket {
            count: 0,
            window_start: now,
        });
        if now.saturating_sub(entry.window_start) >= WINDOW_SECS {
            entry.count = 0;
            entry.window_start = now;
        }
        if entry.count >= REDEEM_MAX_PER_WINDOW {
            return Err(AppError::RateLimited {
                limiter: REDEEM_LIMITER_NAME,
                message: format!(
                    "too many invite redemptions ({REDEEM_MAX_PER_WINDOW} per {WINDOW_SECS}s); try again later"
                ),
                retry_after_secs: (entry.window_start + WINDOW_SECS).saturating_sub(now),
            });
        }
        entry.count += 1;
        Ok(())
    }
}

/// Resolve the client IP from a TCP peer + `X-Forwarded-For` header.
///
/// Behaviour:
/// - `trusted_proxies` empty → always return `peer`.
/// - `peer` not in `trusted_proxies` → return `peer` (the request
///   isn't coming from a configured proxy, so XFF is untrusted).
/// - `peer` in `trusted_proxies` and `xff` provided → walk the XFF
///   list right-to-left, skipping any IP that's also in
///   `trusted_proxies`, and return the first non-trusted entry. If
///   every entry is trusted, return the leftmost (best-effort).
/// - `peer` in `trusted_proxies` but no XFF → return `peer`.
///
/// Malformed XFF entries are silently skipped; if all entries fail to
/// parse, return `peer`.
pub fn resolve_client_ip(peer: IpAddr, xff: Option<&str>, trusted_proxies: &[String]) -> IpAddr {
    if trusted_proxies.is_empty() {
        return peer;
    }

    let trusted: Vec<IpAddr> = trusted_proxies
        .iter()
        .filter_map(|s| s.parse::<IpAddr>().ok())
        .collect();

    if !trusted.contains(&peer) {
        return peer;
    }

    let Some(xff) = xff else {
        return peer;
    };

    // Parse all XFF entries (left-to-right) into IpAddrs, skipping
    // unparseable ones. Empty-after-filter falls through to peer.
    let parsed: Vec<IpAddr> = xff
        .split(',')
        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
        .collect();

    if parsed.is_empty() {
        return peer;
    }

    // Walk right-to-left, returning the first non-trusted IP.
    for candidate in parsed.iter().rev() {
        if !trusted.contains(candidate) {
            return *candidate;
        }
    }

    // Every entry is trusted — fall back to the leftmost (the
    // documented "original client" position).
    parsed[0]
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::Ipv4Addr;

    fn ip(s: &str) -> IpAddr {
        s.parse().unwrap()
    }

    // --- IpRateLimiter ---

    #[test]
    fn allows_under_cap() {
        let l = IpRateLimiter::new();
        let p = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        for _ in 0..MAX_PER_WINDOW {
            l.try_consume(p, 1000).unwrap();
        }
        assert_eq!(l.count(p), MAX_PER_WINDOW);
    }

    #[test]
    fn rejects_over_cap() {
        let l = IpRateLimiter::new();
        let p = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        for _ in 0..MAX_PER_WINDOW {
            l.try_consume(p, 1000).unwrap();
        }
        let err = l.try_consume(p, 1000).unwrap_err();
        assert!(matches!(
            err,
            AppError::RateLimited {
                limiter: LIMITER_NAME,
                retry_after_secs: WINDOW_SECS,
                ..
            }
        ));
    }

    /// The retry hint is the time left in the current window, not the whole
    /// window: a refusal 45s into a 60s window says 15.
    #[test]
    fn retry_after_is_the_remainder_of_the_window() {
        let l = IpRateLimiter::new();
        let p = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        for _ in 0..MAX_PER_WINDOW {
            l.try_consume(p, 1000).unwrap();
        }
        let err = l.try_consume(p, 1045).unwrap_err();
        assert!(matches!(
            err,
            AppError::RateLimited {
                retry_after_secs: 15,
                ..
            }
        ));
    }

    /// Window rolls over: after `WINDOW_SECS` elapsed, a fresh round
    /// of `MAX_PER_WINDOW` requests is allowed. Pinning this catches
    /// a regression where the lazy-reset is moved from
    /// `try_consume` to a sweep task that doesn't run in tests.
    #[test]
    fn window_resets_after_expiry() {
        let l = IpRateLimiter::new();
        let p = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        // Fill the first window.
        for _ in 0..MAX_PER_WINDOW {
            l.try_consume(p, 1000).unwrap();
        }
        // Inside the window — rejected.
        assert!(l.try_consume(p, 1000).is_err());
        // After WINDOW_SECS — accepted.
        l.try_consume(p, 1000 + WINDOW_SECS).unwrap();
        assert_eq!(l.count(p), 1);
    }

    /// Distinct IPs share no state — one IP being rate-limited
    /// doesn't affect another.
    #[test]
    fn distinct_ips_independent() {
        let l = IpRateLimiter::new();
        let a = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
        let b = IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8));
        for _ in 0..MAX_PER_WINDOW {
            l.try_consume(a, 1000).unwrap();
        }
        // a is full, b is fresh.
        assert!(l.try_consume(a, 1000).is_err());
        l.try_consume(b, 1000).unwrap();
    }

    // --- resolve_client_ip ---

    #[test]
    fn resolve_no_trusted_proxies_returns_peer() {
        let peer = ip("203.0.113.1");
        // Even with XFF set, with no trusted_proxies the header is ignored.
        assert_eq!(resolve_client_ip(peer, Some("198.51.100.1"), &[]), peer);
    }

    #[test]
    fn resolve_peer_not_in_trusted_returns_peer() {
        let peer = ip("203.0.113.1");
        let trusted = vec!["10.0.0.1".to_string()];
        assert_eq!(
            resolve_client_ip(peer, Some("198.51.100.1"), &trusted),
            peer
        );
    }

    #[test]
    fn resolve_trusted_peer_picks_xff_client() {
        let peer = ip("10.0.0.1");
        let trusted = vec!["10.0.0.1".to_string()];
        // Single client, single trusted proxy.
        assert_eq!(
            resolve_client_ip(peer, Some("198.51.100.1"), &trusted),
            ip("198.51.100.1")
        );
    }

    /// Walk past the trusted-proxy chain to find the original client.
    /// `XFF: client, proxy1, proxy2` → the rightmost entry is the
    /// closest proxy. With proxy1 and proxy2 both trusted, the
    /// original client is the leftmost.
    #[test]
    fn resolve_walks_past_trusted_chain() {
        let peer = ip("10.0.0.2");
        let trusted = vec!["10.0.0.1".to_string(), "10.0.0.2".to_string()];
        assert_eq!(
            resolve_client_ip(peer, Some("198.51.100.1, 10.0.0.1, 10.0.0.2"), &trusted,),
            ip("198.51.100.1")
        );
    }

    /// XFF spoofing: an attacker whose request actually originates
    /// from a non-trusted host injects fake XFF entries trying to
    /// impersonate another IP. With the actual peer not in trusted,
    /// XFF is ignored entirely.
    #[test]
    fn resolve_attacker_spoof_ignored() {
        let peer = ip("203.0.113.99"); // attacker's real IP
        let trusted = vec!["10.0.0.1".to_string()];
        // Attacker tries to claim they came through a trusted proxy.
        assert_eq!(
            resolve_client_ip(peer, Some("198.51.100.5, 10.0.0.1"), &trusted,),
            peer
        );
    }

    #[test]
    fn resolve_malformed_xff_falls_back_to_peer() {
        let peer = ip("10.0.0.1");
        let trusted = vec!["10.0.0.1".to_string()];
        assert_eq!(resolve_client_ip(peer, Some("not-an-ip"), &trusted), peer);
    }

    #[test]
    fn resolve_no_xff_returns_peer() {
        let peer = ip("10.0.0.1");
        let trusted = vec!["10.0.0.1".to_string()];
        assert_eq!(resolve_client_ip(peer, None, &trusted), peer);
    }
}