Skip to main content

axess_core/session/
binding.rs

1//! Session binding: ties a session to client-specific signals to detect hijacking.
2//!
3//! Session binding prevents session hijacking by computing an HMAC-SHA256
4//! fingerprint of client-specific request properties (e.g. the `User-Agent`
5//! header) and storing it in the session. On every subsequent request the
6//! fingerprint is recomputed and compared using constant-time equality. A
7//! mismatch indicates the session cookie may have been stolen and replayed
8//! from a different client, so the session is reset to `Guest`.
9//!
10//! # When bindings are created
11//!
12//! The [`SessionLayer`](super::layer::SessionLayer) pre-computes a
13//! `pending_fingerprint` from the incoming request on every request. This
14//! pending value is stored in
15//! `SessionInner::pending_fingerprint`
16//! and applied (written into [`SessionData::fingerprint`](super::data::SessionData::fingerprint))
17//! at the earliest auth-state transition that moves the session out of `Guest`:
18//!
19//! - **`set_identifying`**: when the user submits their username.
20//! - **`begin_authenticating`**: when a multi-factor flow starts.
21//! - **`set_authenticated` / `advance_factor`**: when authentication completes.
22//!
23//! Binding as early as possible (during `Identifying`) ensures that even
24//! pre-MFA sessions cannot be replayed from a different device. Once the
25//! fingerprint is set it is never overwritten; it persists for the lifetime
26//! of the session.
27//!
28//! # Usage
29//!
30//! ```text
31//! let layer = SessionLayer::new(store, key)
32//!     .with_binding(UserAgentBinding);
33//! ```
34//!
35//! Implement [`SessionBinding`] for custom binding strategies (e.g. combining
36//! User-Agent with IP subnet or TLS channel binding).
37
38use axum::{body::Body, http::Request};
39use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
40use hmac::Mac;
41
42/// Extracts a binding value from a request for session-to-client binding.
43///
44/// The returned string is stored as an HMAC-SHA256 digest in the session.
45/// On subsequent requests, the HMAC is recomputed and compared to detect
46/// session hijacking (cookie theft from a different client).
47///
48/// Return `None` if the binding signal is absent (e.g. no User-Agent header),
49/// in which case binding is skipped for that request.
50///
51/// # Scope: where binding is checked
52///
53/// The fingerprint is recomputed and compared **once per HTTP request**
54/// at [`SessionLayer`](super::layer::SessionLayer) entry. It is not
55/// re-checked at any other point in the request lifecycle. The
56/// implications:
57///
58/// - **Persistent connections (WebSocket, Server-Sent Events) are
59///   bound only at upgrade.** Once the WS handshake completes, the
60///   layer no longer sees the underlying frames: an attacker who
61///   manages to splice into the existing socket can drive it
62///   indefinitely without the binding firing. If you need to
63///   re-validate, do so in your own message handler at
64///   policy-relevant intervals.
65/// - **gRPC streaming** has the same caveat: only the initial HTTP/2
66///   stream-open request runs through this middleware.
67/// - **Long-poll request bodies** that pause for minutes do not
68///   re-check binding mid-flight, but each new HTTP request does, so
69///   a long-poll loop fired from a hijacked client will fail at the
70///   next round trip.
71/// - **Background jobs the application enqueues "as the user"** are
72///   not bound by this trait at all; the application owns that
73///   threat model. If you spawn a long-running task on behalf of a
74///   user, do not assume the user is still legitimately connected
75///   when it finishes.
76pub trait SessionBinding: Send + Sync + 'static {
77    /// Extract the raw binding material from the request.
78    ///
79    /// The library will HMAC-SHA256 the returned bytes (keyed with the session
80    /// signing key) before storing/comparing, so implementations can return raw
81    /// header values without pre-hashing.
82    fn extract(&self, req: &Request<Body>) -> Option<Vec<u8>>;
83}
84
85/// Compute the HMAC-SHA256 fingerprint used for storage and comparison.
86///
87/// Keyed with the session signing key so that an attacker who reads the session
88/// data from the store cannot recompute a valid fingerprint without the key.
89pub(crate) fn compute_fingerprint(
90    binding: &dyn SessionBinding,
91    req: &Request<Body>,
92    signing_key: &[u8; 32],
93) -> Option<String> {
94    let material = binding.extract(req)?;
95    let mut mac = crate::hmac::new_signer(signing_key);
96    mac.update(&material);
97    let result = mac.finalize();
98    Some(URL_SAFE_NO_PAD.encode(result.into_bytes()))
99}
100
101/// Binds the session to the `User-Agent` header.
102///
103/// **Threat model, read this before relying on it.** UA binding catches
104/// only the dumbest hijacking modes: the attacker steals the cookie via
105/// log scraping, browser-extension exfiltration, or a prior breach where
106/// the UA wasn't captured, and replays it from a different client. It
107/// does **not** stop:
108///
109/// - **XSS / cookie-jacking attacks where the attacker is in the
110///   victim's browser.** The User-Agent is plaintext on every request and
111///   trivially copyable; an attacker who exfiltrates the cookie via a
112///   malicious script or browser extension also has full access to
113///   `navigator.userAgent`.
114/// - **Network-level interception** where the attacker observes the
115///   victim's traffic (TLS-stripping proxies, compromised CA, captured
116///   PCAPs). The UA travels in clear with the cookie.
117/// - **Phishing** that proxies the victim's browser to your origin:
118///   the proxy forwards the real UA verbatim.
119///
120/// What it *is* useful for: defense in depth against database/log dumps
121/// where the attacker has cookies but no captured UA, and as a cheap
122/// signal for hijack telemetry. Combine with at least one of: client-IP
123/// `/24` binding (acceptable when users don't roam between networks
124/// often), TLS channel binding (RFC 8471), or a hardware-bound key
125/// (FIDO2). Document the limits to your security reviewers; assuming
126/// UA binding stops cookie theft is a common misreading.
127#[derive(Debug, Clone, Default)]
128pub struct UserAgentBinding;
129
130impl SessionBinding for UserAgentBinding {
131    fn extract(&self, req: &Request<Body>) -> Option<Vec<u8>> {
132        req.headers()
133            .get(axum::http::header::USER_AGENT)
134            .map(|v| v.as_bytes().to_vec())
135    }
136}
137
138#[cfg(test)]
139mod binding_tests {
140    use super::*;
141    use axum::http::HeaderValue;
142
143    fn req_with_ua(ua: &str) -> Request<Body> {
144        let mut r = Request::new(Body::empty());
145        r.headers_mut().insert(
146            axum::http::header::USER_AGENT,
147            HeaderValue::from_str(ua).unwrap(),
148        );
149        r
150    }
151
152    /// `UserAgentBinding::extract` returns the User-Agent
153    /// header bytes when present and `None` when absent. Pins the
154    /// four `extract -> None / Some(vec![])/Some(vec![0])/Some(vec![1])`
155    /// body replacements.
156    #[test]
157    fn user_agent_binding_extract_returns_header_bytes_or_none() {
158        let binding = UserAgentBinding;
159
160        // Present UA → Some(exact bytes).
161        let req = req_with_ua("Mozilla/5.0 (test)");
162        let extracted = binding.extract(&req).expect("UA present must yield Some");
163        assert_eq!(
164            extracted,
165            b"Mozilla/5.0 (test)".to_vec(),
166            "extract must return UA bytes verbatim; kills Some(vec![]) / Some(vec![0]) / Some(vec![1]) replacements"
167        );
168
169        // Absent UA → None.
170        let req = Request::new(Body::empty());
171        assert!(
172            binding.extract(&req).is_none(),
173            "missing UA must yield None; kills Some(...) body replacements when no header present"
174        );
175    }
176
177    /// `compute_fingerprint` returns `Some(non-empty base64)`
178    /// when the binding extracts material, and the digest depends on
179    /// both the material and the signing key (HMAC contract). Pins
180    /// the body mutations: `None` (would break binding everywhere),
181    /// `Some(String::new())` (would compare empty to empty → all
182    /// requests look bound), and `Some("xyzzy")` (would brick binding
183    /// to a constant value).
184    #[test]
185    fn compute_fingerprint_is_deterministic_and_keyed() {
186        let binding = UserAgentBinding;
187        let req_a = req_with_ua("Mozilla/5.0 (browser-A)");
188        let req_b = req_with_ua("curl/8.0");
189        let key1: [u8; 32] = [1u8; 32];
190        let key2: [u8; 32] = [2u8; 32];
191
192        // Some + non-empty.
193        let fp = compute_fingerprint(&binding, &req_a, &key1)
194            .expect("with material, fingerprint must be Some");
195        assert!(
196            !fp.is_empty(),
197            "fingerprint must not be empty (kills String::new())"
198        );
199        assert_ne!(fp, "xyzzy", "fingerprint must not be the constant 'xyzzy'");
200
201        // Determinism: same input → same output.
202        let fp_again = compute_fingerprint(&binding, &req_a, &key1).unwrap();
203        assert_eq!(fp, fp_again, "fingerprint must be deterministic");
204
205        // Material-sensitive: different UA → different fingerprint.
206        let fp_b = compute_fingerprint(&binding, &req_b, &key1).unwrap();
207        assert_ne!(
208            fp, fp_b,
209            "different binding material must yield different fingerprint"
210        );
211
212        // Key-sensitive: different signing key → different fingerprint
213        // (this is the HMAC keying property; kills any const-return mutant
214        // that would ignore the key).
215        let fp_k2 = compute_fingerprint(&binding, &req_a, &key2).unwrap();
216        assert_ne!(
217            fp, fp_k2,
218            "different signing key must yield different fingerprint"
219        );
220
221        // No material → None (the helper threads the binding's `extract`
222        // result through `?`; if extract returns None, the helper must too).
223        let req_no_ua = Request::new(Body::empty());
224        assert!(
225            compute_fingerprint(&binding, &req_no_ua, &key1).is_none(),
226            "no binding material must yield None; kills Some(...) body replacements"
227        );
228    }
229}