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}