Skip to main content

acme_proxy/webadmin/
session.rs

1//! Session tokens, the cookie they travel in, the extractors that resolve
2//! them, the CSRF check, and the login rate limiter.
3//!
4//! ## The token
5//!
6//! 32 bytes from the system CSPRNG, base64url-unpadded (43 characters, cookie-
7//! safe unquoted). Stored as `hex(SHA-256(token))` and never in plaintext, so
8//! a read of the database — a backup, a `.dump`, an injection — yields nothing
9//! replayable. Not the password KDF: the token has 256 bits of entropy, so
10//! there is no dictionary to slow down and a slow hash would buy nothing but
11//! latency on every request. Lookup is by the hash, and the value looked up
12//! *is* the secret, so that path needs no constant-time comparison — unlike
13//! the CSRF token, which is compared against a caller-supplied string.
14//!
15//! ## CSRF, and why `SameSite=Strict` is not enough
16//!
17//! The usual answer is that SameSite covers it. It does not, here, and the
18//! reason is specific: **SameSite is scoped to the registrable domain, not the
19//! origin — different ports of the same host are same-site.** A panel on
20//! `:3001` beside anything else on `:8080` of the same box is exactly the
21//! configuration it does not protect. So there is a per-session token as well,
22//! plus an origin gate that also covers login (which by definition carries no
23//! session token yet).
24//!
25//! Enforcement is **structural**: [`AuthenticatedWrite`] is the only way for a
26//! mutating handler to reach a session, and constructing it runs the check.
27//! Same reasoning as hoisting the media-type/`crit`/`url`/nonce checks into
28//! `AcmeRequest` — a new endpoint cannot forget what it cannot express.
29
30use std::collections::HashMap;
31use std::net::IpAddr;
32use std::sync::Mutex;
33use std::time::Duration;
34
35use axum::extract::FromRequestParts;
36use axum::http::request::Parts;
37use axum::http::{HeaderMap, header};
38use base64::Engine as _;
39use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
40use ring::rand::{SecureRandom, SystemRandom};
41use subtle::ConstantTimeEq;
42use tracing::{info, warn};
43
44use crate::sqlite::admin_session::AdminSession;
45use crate::sqlite::admin_user::AdminUser;
46use crate::sqlite::nonce::{fingerprint, now_secs};
47use crate::webadmin::AdminState;
48use crate::webadmin::error::AdminError;
49
50/// The session cookie's name.
51///
52/// The `__Host-` prefix is free, browser-enforced hardening: it *requires*
53/// `Secure`, `Path=/` and no `Domain`, which is exactly the spec chosen below.
54/// A future edit that drops `Secure` therefore breaks the cookie visibly
55/// instead of silently widening it.
56pub const COOKIE_NAME: &str = "__Host-acme_admin_session";
57
58/// The header carrying the per-session CSRF token on unsafe methods.
59pub const CSRF_HEADER: &str = "x-csrf-token";
60
61/// Session token length in bytes. 256 bits — the same size
62/// `sqlite::eab::generate_secret` chooses, and for the same reason.
63const TOKEN_LEN: usize = 32;
64
65/// How stale `last_seen_at` may get before a request bothers to advance it.
66///
67/// Without this, a page polling every few seconds would take the WAL writer
68/// lock on every request just to move a timestamp by a second.
69const SESSION_TOUCH_INTERVAL: i64 = 60;
70
71/// How long a half-authenticated session may sit unfinished.
72///
73/// Deliberately **not** `admin.session_ttl_seconds` and deliberately not a
74/// configuration key. A `pending_mfa` row is a password that has been accepted
75/// and nothing more; it should not outlive the tab that created it. Five
76/// minutes is longer than reading a code off a phone and shorter than walking
77/// away from the keyboard -- a UI timing constant, not a policy an operator
78/// tunes.
79///
80/// It is also the only per-session bound on code guessing: within it an
81/// attacker holding a correct password gets `admin.login_max_attempts` tries
82/// from one address, and then the row is gone regardless.
83pub const PENDING_MFA_TTL: Duration = Duration::from_secs(300);
84
85/// A freshly minted session token, and the hash to store for it.
86pub struct MintedToken {
87    /// Goes in the `Set-Cookie` and is never written down.
88    pub token: String,
89    /// Goes in `admin_sessions.token_hash`.
90    pub token_hash: String,
91}
92
93/// Mints a session token. RNG failure is unrecoverable, as elsewhere in this
94/// crate.
95#[must_use]
96pub fn mint_token() -> MintedToken {
97    let mut bytes = [0u8; TOKEN_LEN];
98    SystemRandom::new()
99        .fill(&mut bytes)
100        .expect("system RNG unavailable");
101    let token = BASE64_URL_SAFE_NO_PAD.encode(bytes);
102    let token_hash = hash_token(&token);
103    MintedToken { token, token_hash }
104}
105
106/// Mints a CSRF token. Same entropy as a session token — it is stored in
107/// plaintext, but it still has to be unguessable.
108#[must_use]
109pub fn mint_csrf_token() -> String {
110    let mut bytes = [0u8; TOKEN_LEN];
111    SystemRandom::new()
112        .fill(&mut bytes)
113        .expect("system RNG unavailable");
114    BASE64_URL_SAFE_NO_PAD.encode(bytes)
115}
116
117/// `hex(SHA-256(token))` — the `admin_sessions` primary key.
118#[must_use]
119pub fn hash_token(token: &str) -> String {
120    let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
121    hex::encode(digest.as_ref())
122}
123
124/// The `Set-Cookie` value that establishes a session.
125#[must_use]
126pub fn session_cookie(token: &str, ttl: Duration) -> String {
127    format!(
128        "{COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={}",
129        ttl.as_secs()
130    )
131}
132
133/// The `Set-Cookie` value that clears one.
134#[must_use]
135pub fn clearing_cookie() -> String {
136    format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
137}
138
139/// Reads the session token out of a `Cookie` header set.
140///
141/// Hand-rolled rather than pulling in `axum-extra` or `cookie` for twenty
142/// lines. Takes the **first** match, across all `Cookie` headers: a crafted
143/// request carrying the name twice must not let the second one win, which is a
144/// session-fixation vector.
145#[must_use]
146pub fn cookie_value(headers: &HeaderMap) -> Option<String> {
147    for header in headers.get_all(header::COOKIE) {
148        let Ok(raw) = header.to_str() else { continue };
149        for pair in raw.split(';') {
150            let Some((name, value)) = pair.split_once('=') else {
151                continue;
152            };
153            if name.trim() == COOKIE_NAME {
154                // A cookie value may be quoted (RFC 6265 §4.1.1).
155                let value = value.trim();
156                let value = value
157                    .strip_prefix('"')
158                    .and_then(|v| v.strip_suffix('"'))
159                    .unwrap_or(value);
160                return Some(value.to_string());
161            }
162        }
163    }
164    None
165}
166
167/// The peer address of an admin request, if the socket carried one.
168///
169/// Its own extractor rather than [`crate::filter::client_ip::ClientIp`],
170/// because that one is populated by the ACME filter middleware and this
171/// listener deliberately runs none (see `build_admin_app`).
172///
173/// **No forwarded-header handling.** `filter.trusted_proxies` governs the ACME
174/// listener, and honouring `X-Forwarded-For` here without an equivalent
175/// allowlist would let any caller spoof the key the login limiter counts on.
176/// Behind a reverse proxy the limiter therefore counts the proxy — which is a
177/// real limitation, and the reason the recommended way to reach this listener
178/// remotely is an SSH tunnel rather than a proxy.
179#[derive(Debug, Clone, Copy)]
180pub struct AdminClientIp(pub Option<IpAddr>);
181
182impl<S: Sync> FromRequestParts<S> for AdminClientIp {
183    type Rejection = std::convert::Infallible;
184
185    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
186        let address = parts
187            .extensions
188            .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
189            // Canonicalized for the same reason `ProxyPolicy::resolve` does it:
190            // a dual-stack listener sees an IPv4 client as `::ffff:…`, and two
191            // spellings of one address would be two buckets.
192            .map(|info| info.0.ip().to_canonical());
193        Ok(AdminClientIp(address))
194    }
195}
196
197/// What still stands between a `pending_mfa` cookie and a usable session.
198///
199/// Not derivable from the session row alone, which is why it is carried rather
200/// than recomputed: both states are one `pending_mfa` row, and the difference
201/// is `user.has_totp()`. Making each caller re-derive it is exactly the drift
202/// this codebase avoids.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum MfaStep {
205    /// A confirmed factor exists: prove a code, or spend a recovery code.
206    Verify,
207    /// `admin.require_mfa` is on and this operator has none: enrol first.
208    Enrol,
209}
210
211impl MfaStep {
212    /// The wire and template spelling.
213    #[must_use]
214    pub fn as_str(self) -> &'static str {
215        match self {
216            MfaStep::Verify => "verify",
217            MfaStep::Enrol => "enrol",
218        }
219    }
220}
221
222/// A live session and the operator it belongs to. Read-only handlers take this.
223#[derive(Debug)]
224pub struct Authenticated {
225    pub session: AdminSession,
226    pub user: AdminUser,
227}
228
229/// [`Authenticated`], plus the CSRF token and the origin gate.
230///
231/// Every mutating handler takes this. The check cannot be forgotten, because
232/// this type is the only way a `POST`/`PATCH`/`DELETE` handler can reach a
233/// session at all.
234#[derive(Debug)]
235pub struct AuthenticatedWrite(pub Authenticated);
236
237impl FromRequestParts<AdminState> for Authenticated {
238    type Rejection = AdminError;
239
240    async fn from_request_parts(
241        parts: &mut Parts,
242        state: &AdminState,
243    ) -> Result<Self, Self::Rejection> {
244        resolve_session(parts, state).await
245    }
246}
247
248impl FromRequestParts<AdminState> for AuthenticatedWrite {
249    type Rejection = AdminError;
250
251    async fn from_request_parts(
252        parts: &mut Parts,
253        state: &AdminState,
254    ) -> Result<Self, Self::Rejection> {
255        // The origin gate first: it is free, and it refuses a cross-origin
256        // request before a database lookup happens on its behalf.
257        check_origin(&parts.headers, &state.config.admin.base_url)?;
258        let authenticated = resolve_session(parts, state).await?;
259        check_csrf(&parts.headers, &authenticated.session.csrf_token)?;
260        Ok(AuthenticatedWrite(authenticated))
261    }
262}
263
264/// A session with a verified password and nothing more.
265///
266/// Its own extractor with its own resolver, because [`resolve_session`] hard-
267/// refuses a non-`active` session -- deliberately, so no ordinary route can
268/// accept a half-authenticated one. This is its exact mirror image: it refuses
269/// an `active` one.
270#[derive(Debug)]
271pub struct PendingMfa {
272    pub session: AdminSession,
273    pub user: AdminUser,
274    pub step: MfaStep,
275}
276
277/// [`PendingMfa`] with the **origin gate only, and no CSRF check.**
278///
279/// The omission is deliberate, and is the same one `POST /ui/login` already
280/// makes one step earlier: the challenge page is a plain form -- sign-in must
281/// work with JavaScript off, which `tests/admin_pages.rs` pins for `login.html`
282/// -- and [`check_csrf`] reads a header a form cannot set. Teaching it to read a
283/// form field instead would be a second CSRF path, which is what
284/// `pages::auth`'s whole shape exists to prevent.
285///
286/// What covers the route is [`check_origin`], and the residual risk is nil: a
287/// cross-site forger would need a valid code for a session they cannot read,
288/// and success would only complete the victim's own login.
289#[derive(Debug)]
290pub struct PendingMfaSubmit(pub PendingMfa);
291
292/// A session allowed to *set up* a factor.
293///
294/// Either an `active` session -- voluntary enrolment, or moving to a new phone
295/// -- or a `pending_mfa` one that exists precisely because the operator has none
296/// and `admin.require_mfa` is on.
297///
298/// **The `pending_mfa` case requires `!user.has_totp()`, and that condition is
299/// the whole security of this type.** A session that owes a *code* must never
300/// reach an enrolment route, or the second factor is bypassable by enrolling a
301/// new one over it.
302///
303/// Runs [`check_origin`] and [`check_csrf`] exactly as [`AuthenticatedWrite`]
304/// does: unlike the challenge form, these routes are htmx calls from a page
305/// that was handed a token.
306#[derive(Debug)]
307pub struct EnrolWrite {
308    pub session: AdminSession,
309    pub user: AdminUser,
310    /// `true` when this session is still `pending_mfa`, so the handler knows to
311    /// promote it once the enrolment confirms.
312    pub pending: bool,
313}
314
315impl FromRequestParts<AdminState> for PendingMfa {
316    type Rejection = AdminError;
317
318    async fn from_request_parts(
319        parts: &mut Parts,
320        state: &AdminState,
321    ) -> Result<Self, Self::Rejection> {
322        resolve_pending(parts, state).await
323    }
324}
325
326impl FromRequestParts<AdminState> for PendingMfaSubmit {
327    type Rejection = AdminError;
328
329    async fn from_request_parts(
330        parts: &mut Parts,
331        state: &AdminState,
332    ) -> Result<Self, Self::Rejection> {
333        check_origin(&parts.headers, &state.config.admin.base_url)?;
334        Ok(PendingMfaSubmit(resolve_pending(parts, state).await?))
335    }
336}
337
338impl FromRequestParts<AdminState> for EnrolWrite {
339    type Rejection = AdminError;
340
341    async fn from_request_parts(
342        parts: &mut Parts,
343        state: &AdminState,
344    ) -> Result<Self, Self::Rejection> {
345        check_origin(&parts.headers, &state.config.admin.base_url)?;
346        let (_, session, user) = resolve_live(parts, state).await?;
347
348        // The refusal this type exists for. A `pending_mfa` session whose owner
349        // already *has* a factor owes a code, and letting it enrol a second one
350        // would make the first optional.
351        if !session.is_active() && user.has_totp() {
352            return Err(AdminError::session_invalid());
353        }
354
355        check_csrf(&parts.headers, &session.csrf_token)?;
356        let pending = !session.is_active();
357        Ok(EnrolWrite {
358            session,
359            user,
360            pending,
361        })
362    }
363}
364
365/// Cookie → live row → live user.
366///
367/// Judges *liveness* only -- expiry, idleness, the orphan case, a disabled owner
368/// -- and never `state`. That is what lets two extractors sit over one lookup
369/// without either being a widening of the other: each adds its own opposite
370/// state check on top.
371async fn resolve_live(
372    parts: &Parts,
373    state: &AdminState,
374) -> Result<(String, AdminSession, AdminUser), AdminError> {
375    let token = cookie_value(&parts.headers).ok_or_else(AdminError::session_invalid)?;
376    let token_hash = hash_token(&token);
377
378    let Some(session) = AdminSession::find_by_token_hash(&token_hash, &state.database).await?
379    else {
380        return Err(AdminError::session_invalid());
381    };
382
383    let now = now_secs();
384    if session.is_expired(now) {
385        AdminSession::delete(&token_hash, &state.database).await?;
386        return Err(AdminError::session_expired());
387    }
388    let idle_timeout = Duration::from_secs(state.config.admin.session_idle_timeout_seconds);
389    if session.is_idle(now, idle_timeout) {
390        AdminSession::delete(&token_hash, &state.database).await?;
391        return Err(AdminError::session_idle());
392    }
393
394    let Some(user) = AdminUser::find_by_id(&session.user_id, &state.database).await? else {
395        // The FK cascade should make this impossible; if it happens, the
396        // session is orphaned and must not authenticate anybody.
397        warn!(event = "admin_session_orphaned", outcome = "failure", session_fp = %fingerprint(&token_hash));
398        AdminSession::delete(&token_hash, &state.database).await?;
399        return Err(AdminError::session_invalid());
400    };
401    if !user.is_active() {
402        return Err(AdminError::session_invalid());
403    }
404
405    Ok((token_hash, session, user))
406}
407
408/// Resolves the cookie to a live, **fully authenticated** session, refusing each
409/// way it can be dead with its own code — the client is told to sign in again
410/// either way, but the operator reading a log can tell an expiry from a
411/// revocation.
412async fn resolve_session(parts: &Parts, state: &AdminState) -> Result<Authenticated, AdminError> {
413    let (_, mut session, user) = resolve_live(parts, state).await?;
414
415    // `pending_mfa`: a password was accepted and nothing more. Only the routes
416    // that finish the login may see such a session, and they go through
417    // `resolve_pending` instead.
418    if !session.is_active() {
419        return Err(AdminError::session_invalid());
420    }
421
422    if now_secs() - session.last_seen_at >= SESSION_TOUCH_INTERVAL {
423        session.touch(&state.database).await?;
424    }
425
426    Ok(Authenticated { session, user })
427}
428
429/// [`resolve_session`]'s mirror image: a live session that is **not** yet
430/// active.
431///
432/// Does not touch `last_seen_at`. A pending row has a five-minute absolute
433/// deadline, so advancing an idle deadline it will never reach would be pure
434/// cost on the login path.
435async fn resolve_pending(parts: &Parts, state: &AdminState) -> Result<PendingMfa, AdminError> {
436    let (_, session, user) = resolve_live(parts, state).await?;
437
438    if session.is_active() {
439        return Err(AdminError::session_invalid());
440    }
441
442    let step = if user.has_totp() {
443        MfaStep::Verify
444    } else {
445        MfaStep::Enrol
446    };
447    Ok(PendingMfa {
448        session,
449        user,
450        step,
451    })
452}
453
454/// Compares the request's `X-CSRF-Token` against the session's, in constant
455/// time.
456pub fn check_csrf(headers: &HeaderMap, expected: &str) -> Result<(), AdminError> {
457    let Some(supplied) = headers.get(CSRF_HEADER).and_then(|v| v.to_str().ok()) else {
458        return Err(AdminError::csrf_failed(format!(
459            "this request needs an {CSRF_HEADER} header carrying the session's csrfToken"
460        )));
461    };
462
463    // `ct_eq` is only constant-time across equal lengths; comparing the
464    // lengths first leaks nothing a token's own encoding does not already fix.
465    let matches = supplied.len() == expected.len()
466        && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes()));
467    if !matches {
468        return Err(AdminError::csrf_failed(
469            "the CSRF token does not match this session",
470        ));
471    }
472    Ok(())
473}
474
475/// Refuses a request whose `Origin` or `Sec-Fetch-Site` says it came from
476/// somewhere else.
477///
478/// Both headers are checked only when *present*: a non-browser client (curl,
479/// a script) sends neither and is not the threat this addresses. Its value is
480/// covering the login request, which carries no session and therefore no CSRF
481/// token to check.
482pub fn check_origin(headers: &HeaderMap, base_url: &str) -> Result<(), AdminError> {
483    if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok())
484        && site != "same-origin"
485        && site != "none"
486    {
487        return Err(AdminError::csrf_failed(format!(
488            "cross-origin request refused (Sec-Fetch-Site: {site})"
489        )));
490    }
491
492    if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
493        let expected = url::Url::parse(base_url)
494            .map(|u| u.origin().ascii_serialization())
495            .unwrap_or_default();
496        if origin != expected {
497            return Err(AdminError::csrf_failed(format!(
498                "cross-origin request refused (Origin: {origin}, expected {expected})"
499            )));
500        }
501    }
502    Ok(())
503}
504
505/// Fixed-window failed-login counter, keyed by client address.
506///
507/// In-process on purpose: it protects a listener that defaults to loopback and
508/// holds a handful of accounts, and a database-backed counter would add a
509/// write to the very path being flooded.
510#[derive(Debug)]
511pub struct LoginLimiter {
512    max_attempts: u32,
513    window: Duration,
514    buckets: Mutex<HashMap<IpAddr, Bucket>>,
515}
516
517#[derive(Debug, Clone, Copy)]
518struct Bucket {
519    failures: u32,
520    window_started: i64,
521}
522
523impl LoginLimiter {
524    #[must_use]
525    pub fn new(max_attempts: u32, window_seconds: u64) -> Self {
526        Self {
527            max_attempts,
528            window: Duration::from_secs(window_seconds),
529            buckets: Mutex::new(HashMap::new()),
530        }
531    }
532
533    /// The same counters under new limits.
534    ///
535    /// A configuration reload rebuilds the admin router, and with it every value
536    /// `AdminState` derives from `[admin]` — which for this type would mean
537    /// starting from an empty map. That is a security regression, not a cosmetic
538    /// one: a reload in the middle of a brute-force attempt would clear the
539    /// attacker's backoff, and `admin.login_*` is exactly the sort of key an
540    /// operator edits *because* they are being flooded.
541    ///
542    /// Carrying the whole limiter across instead would be the other error,
543    /// leaving `login_max_attempts` and `login_window_seconds` silently stale.
544    /// So the counters move and the limits do not.
545    #[must_use]
546    pub fn rebuilt(&self, max_attempts: u32, window_seconds: u64) -> Self {
547        let buckets = std::mem::take(&mut *self.buckets.lock().unwrap_or_else(|e| e.into_inner()));
548        Self {
549            max_attempts,
550            window: Duration::from_secs(window_seconds),
551            buckets: Mutex::new(buckets),
552        }
553    }
554
555    /// Whether this address may attempt a login now. `Err` carries the seconds
556    /// left in the window.
557    ///
558    /// Called **before** the password hash runs: 600 000 iterations is a
559    /// denial-of-service lever, so a limited caller must not pay it — nor make
560    /// the server pay it.
561    pub fn check(&self, client: Option<IpAddr>) -> Result<(), u64> {
562        // No address means no key. This is not a bypass: the limiter is a
563        // convenience over the bind address and the session, and an admin
564        // listener always has a peer address in practice (`TapIo` carries it
565        // through TLS). Failing closed here would lock out every request
566        // rather than every attacker.
567        let Some(client) = client else { return Ok(()) };
568        let now = now_secs();
569        let window = self.window.as_secs() as i64;
570
571        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
572        // Pruned under the same lock, so the map cannot grow without bound.
573        buckets.retain(|_, bucket| now - bucket.window_started < window);
574
575        match buckets.get(&client) {
576            Some(bucket) if bucket.failures >= self.max_attempts => {
577                Err((window - (now - bucket.window_started)).max(1) as u64)
578            }
579            _ => Ok(()),
580        }
581    }
582
583    /// Records a failed attempt.
584    pub fn record_failure(&self, client: Option<IpAddr>) {
585        let Some(client) = client else { return };
586        let now = now_secs();
587        let window = self.window.as_secs() as i64;
588
589        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
590        let bucket = buckets.entry(client).or_insert(Bucket {
591            failures: 0,
592            window_started: now,
593        });
594        if now - bucket.window_started >= window {
595            *bucket = Bucket {
596                failures: 0,
597                window_started: now,
598            };
599        }
600        bucket.failures += 1;
601    }
602
603    /// Clears an address's counter after a successful login, so one operator
604    /// fumbling their password does not spend the window for the next.
605    pub fn record_success(&self, client: Option<IpAddr>) {
606        let Some(client) = client else { return };
607        self.buckets
608            .lock()
609            .unwrap_or_else(|e| e.into_inner())
610            .remove(&client);
611    }
612}
613
614/// Logs a completed login attempt. One place, so the events cannot drift.
615pub fn log_login(succeeded: bool, username: &str, client: Option<IpAddr>, reason: &'static str) {
616    if succeeded {
617        info!(event = "admin_login_succeeded",
618              outcome = "success",
619              username = %username,
620              client_ip = ?client);
621    } else {
622        warn!(event = "admin_login_failed",
623              outcome = "failure",
624              username = %username,
625              client_ip = ?client,
626              reason = reason);
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use axum::http::HeaderValue;
634
635    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
636        let mut map = HeaderMap::new();
637        for (name, value) in pairs {
638            map.append(
639                header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
640                HeaderValue::from_str(value).unwrap(),
641            );
642        }
643        map
644    }
645
646    #[test]
647    fn a_minted_token_is_43_url_safe_characters_and_hashes_stably() {
648        let minted = mint_token();
649        assert_eq!(minted.token.len(), 43, "32 bytes, base64url unpadded");
650        assert!(!minted.token.contains('='));
651        assert!(!minted.token.contains('+'));
652        assert!(!minted.token.contains('/'));
653        assert_eq!(minted.token_hash, hash_token(&minted.token));
654        assert_eq!(minted.token_hash.len(), 64, "SHA-256 as hex");
655
656        // Distinct per call, and the hash never contains the token.
657        assert_ne!(mint_token().token, minted.token);
658        assert!(!minted.token_hash.contains(&minted.token));
659    }
660
661    #[test]
662    fn hash_token_matches_a_known_vector() {
663        // sha256("") — pins the encoding (hex, lowercase) as well as the digest.
664        assert_eq!(
665            hash_token(""),
666            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
667        );
668    }
669
670    #[test]
671    fn csrf_tokens_are_unguessable_and_distinct() {
672        let first = mint_csrf_token();
673        assert_eq!(first.len(), 43);
674        assert_ne!(first, mint_csrf_token());
675    }
676
677    #[test]
678    fn the_session_cookie_carries_every_required_attribute() {
679        let cookie = session_cookie("the-token", Duration::from_secs(43_200));
680        assert!(cookie.starts_with("__Host-acme_admin_session=the-token;"));
681        assert!(cookie.contains("HttpOnly"));
682        assert!(cookie.contains("Secure"));
683        assert!(cookie.contains("SameSite=Strict"));
684        assert!(cookie.contains("Path=/"));
685        assert!(cookie.contains("Max-Age=43200"));
686        // `__Host-` forbids it, and setting one would widen the cookie to
687        // every subdomain.
688        assert!(!cookie.contains("Domain"));
689    }
690
691    #[test]
692    fn the_clearing_cookie_expires_immediately_and_keeps_the_same_attributes() {
693        let cookie = clearing_cookie();
694        assert!(cookie.starts_with("__Host-acme_admin_session=;"));
695        assert!(cookie.contains("Max-Age=0"));
696        // A browser matches on name+path+domain, so a clear that dropped these
697        // would leave the original cookie in place.
698        assert!(cookie.contains("Path=/"));
699        assert!(cookie.contains("Secure"));
700        assert!(cookie.contains("HttpOnly"));
701    }
702
703    /// A parser case: a label, the `Cookie` headers to send, and the value the
704    /// parser must pick out.
705    type CookieCase = (
706        &'static str,
707        Vec<(&'static str, String)>,
708        Option<&'static str>,
709    );
710
711    #[test]
712    fn cookie_parsing_is_table_driven() {
713        let name = COOKIE_NAME;
714        let cases: Vec<CookieCase> = vec![
715            ("absent entirely", vec![], None),
716            (
717                "the only cookie",
718                vec![("cookie", format!("{name}=abc"))],
719                Some("abc"),
720            ),
721            (
722                "among others",
723                vec![("cookie", format!("theme=dark; {name}=abc; lang=en"))],
724                Some("abc"),
725            ),
726            (
727                "leading whitespace",
728                vec![("cookie", format!("theme=dark;   {name}=abc"))],
729                Some("abc"),
730            ),
731            (
732                "a quoted value",
733                vec![("cookie", format!("{name}=\"abc\""))],
734                Some("abc"),
735            ),
736            (
737                "a segment with no equals sign",
738                vec![("cookie", format!("broken; {name}=abc"))],
739                Some("abc"),
740            ),
741            (
742                "present but empty",
743                vec![("cookie", format!("{name}="))],
744                Some(""),
745            ),
746            (
747                "a different cookie only",
748                vec![("cookie", "other=abc".to_string())],
749                None,
750            ),
751            (
752                // The fixation vector: a crafted header repeating the name
753                // must not let the *second* value win.
754                "duplicated in one header",
755                vec![("cookie", format!("{name}=first; {name}=second"))],
756                Some("first"),
757            ),
758            (
759                "duplicated across two headers",
760                vec![
761                    ("cookie", format!("{name}=first")),
762                    ("cookie", format!("{name}=second")),
763                ],
764                Some("first"),
765            ),
766            (
767                "a name that merely contains ours",
768                vec![("cookie", format!("x{name}=nope"))],
769                None,
770            ),
771        ];
772
773        for (label, pairs, expected) in cases {
774            let owned: Vec<(&str, &str)> = pairs.iter().map(|(n, v)| (*n, v.as_str())).collect();
775            assert_eq!(
776                cookie_value(&headers(&owned)).as_deref(),
777                expected,
778                "case `{label}`"
779            );
780        }
781    }
782
783    #[test]
784    fn the_csrf_check_accepts_only_an_exact_match() {
785        let expected = "the-expected-token";
786        assert!(check_csrf(&headers(&[(CSRF_HEADER, expected)]), expected).is_ok());
787
788        // Missing.
789        let error = check_csrf(&HeaderMap::new(), expected).unwrap_err();
790        assert_eq!(error.code, "csrf_failed");
791        assert!(error.message.contains(CSRF_HEADER));
792
793        // Wrong, and wrong-length (the branch that short-circuits `ct_eq`).
794        for supplied in [
795            "",
796            "wrong",
797            "the-expected-token-but-longer",
798            "the-expected-toke",
799        ] {
800            let error = check_csrf(&headers(&[(CSRF_HEADER, supplied)]), expected).unwrap_err();
801            assert_eq!(error.code, "csrf_failed", "for `{supplied}`");
802        }
803    }
804
805    #[test]
806    fn the_origin_gate_covers_the_cases_a_browser_produces() {
807        let base = "http://localhost:3001";
808
809        // Neither header: a script or curl. Not the threat this addresses.
810        assert!(check_origin(&HeaderMap::new(), base).is_ok());
811
812        // Same-origin, both spellings a browser uses.
813        assert!(check_origin(&headers(&[("sec-fetch-site", "same-origin")]), base).is_ok());
814        assert!(check_origin(&headers(&[("sec-fetch-site", "none")]), base).is_ok());
815        assert!(check_origin(&headers(&[("origin", base)]), base).is_ok());
816
817        for site in ["cross-site", "same-site"] {
818            let error = check_origin(&headers(&[("sec-fetch-site", site)]), base).unwrap_err();
819            assert_eq!(error.code, "csrf_failed", "for {site}");
820            // `same-site` is the one SameSite=Strict would have let through:
821            // a different port of the same host.
822            assert!(error.message.contains(site));
823        }
824
825        let error = check_origin(&headers(&[("origin", "http://evil.example")]), base).unwrap_err();
826        assert!(error.message.contains("evil.example"));
827
828        // A different port of the same host is a different origin.
829        let error =
830            check_origin(&headers(&[("origin", "http://localhost:8080")]), base).unwrap_err();
831        assert_eq!(error.code, "csrf_failed");
832    }
833
834    fn ip(last: u8) -> Option<IpAddr> {
835        Some(IpAddr::from([192, 0, 2, last]))
836    }
837
838    #[test]
839    fn the_limiter_permits_up_to_the_limit_then_refuses() {
840        let limiter = LoginLimiter::new(3, 300);
841
842        for attempt in 0..3 {
843            assert!(limiter.check(ip(1)).is_ok(), "attempt {attempt} must pass");
844            limiter.record_failure(ip(1));
845        }
846
847        let retry_after = limiter.check(ip(1)).unwrap_err();
848        assert!(retry_after > 0 && retry_after <= 300, "got {retry_after}");
849
850        // Scoped to the address that failed.
851        assert!(limiter.check(ip(2)).is_ok());
852    }
853
854    #[test]
855    fn a_success_clears_the_counter() {
856        let limiter = LoginLimiter::new(2, 300);
857        limiter.record_failure(ip(1));
858        limiter.record_success(ip(1));
859        limiter.record_failure(ip(1));
860        assert!(
861            limiter.check(ip(1)).is_ok(),
862            "the pre-success failure must not still count"
863        );
864    }
865
866    #[test]
867    fn the_window_rolls_over_and_prunes() {
868        // A one-second window, so the rollover is observable without sleeping
869        // on a wall clock the test does not control.
870        let limiter = LoginLimiter::new(1, 1);
871        limiter.record_failure(ip(1));
872        assert!(limiter.check(ip(1)).is_err());
873
874        // Backdate the bucket past the window.
875        {
876            let mut buckets = limiter.buckets.lock().unwrap();
877            buckets.get_mut(&ip(1).unwrap()).unwrap().window_started -= 5;
878        }
879        assert!(limiter.check(ip(1)).is_ok(), "the window must roll over");
880        assert!(
881            limiter.buckets.lock().unwrap().is_empty(),
882            "a stale bucket must be pruned, or the map grows without bound"
883        );
884    }
885
886    #[test]
887    fn a_missing_client_address_is_not_limited() {
888        let limiter = LoginLimiter::new(1, 300);
889        limiter.record_failure(None);
890        limiter.record_success(None);
891        assert!(
892            limiter.check(None).is_ok(),
893            "failing closed here would lock out every request, not every attacker"
894        );
895    }
896
897    #[test]
898    fn log_login_renders_both_outcomes() {
899        // The `tracing` field expressions only execute when something is
900        // subscribed; `tests/common` installs a sink, and here the call is
901        // simply exercised for its branches.
902        log_login(true, "alice", ip(1), "");
903        log_login(false, "alice", ip(1), "wrong_password");
904        log_login(false, "alice", None, "unknown_user");
905    }
906}