Skip to main content

session_guard/
risk.rs

1//! Scores the current request against a session's baseline.
2
3use chrono::{DateTime, TimeDelta, Utc};
4
5use crate::{
6    checks::{Check, Checks, Decision, Thresholds},
7    fingerprint::{Baseline, Fingerprint, os_compatible},
8};
9
10/// The checks that fired for a request and their summed weight.
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Assessment {
14    /// Sum of the weights of `signals`.
15    pub score: u32,
16    /// Enabled checks that fired, in [`Check::ALL`] order.
17    pub signals: Vec<Check>,
18}
19
20impl Assessment {
21    /// Maps the score onto a decision.
22    pub fn decision(&self, thresholds: &Thresholds) -> Decision {
23        thresholds.decide(self.score)
24    }
25}
26
27/// Compares `current` against `baseline`.
28///
29/// A value the baseline knows but the request lacks counts as a change: stripping headers
30/// never lowers the score. Values the baseline never had are not compared.
31pub fn assess(
32    baseline: &Baseline,
33    current: &Fingerprint,
34    now: DateTime<Utc>,
35    ping_pong_window: TimeDelta,
36    checks: &Checks,
37) -> Assessment {
38    let known = &baseline.fingerprint;
39    let mut fired = Vec::with_capacity(4);
40
41    if let (Some(a), Some(b)) = (&known.os, &current.os)
42        && !os_compatible(a, b)
43    {
44        fired.push(Check::OsChanged);
45    }
46
47    if let (Some(a), Some(b)) = (&known.browser, &current.browser) {
48        if a != b {
49            fired.push(Check::BrowserChanged);
50        } else if let (Some(a), Some(b)) = (known.browser_major, current.browser_major)
51            && b < a
52        {
53            fired.push(Check::BrowserDowngraded);
54        }
55    }
56
57    if lost(known.browser.as_ref(), current.browser.as_ref()) || lost(known.os.as_ref(), current.os.as_ref()) {
58        fired.push(Check::UserAgentMissing);
59    }
60
61    if let (Some(a), Some(b)) = (&known.ch_platform, &current.ch_platform)
62        && !os_compatible(a, b)
63    {
64        fired.push(Check::PlatformHintChanged);
65    }
66
67    if lost(known.ch_platform.as_ref(), current.ch_platform.as_ref())
68        || lost(known.ch_mobile.as_ref(), current.ch_mobile.as_ref())
69    {
70        fired.push(Check::ClientHintsMissing);
71    }
72
73    if matches!((known.ch_mobile, current.ch_mobile), (Some(a), Some(b)) if a != b) {
74        fired.push(Check::MobileHintChanged);
75    }
76
77    if let (Some(platform), Some(os)) = (&current.ch_platform, &current.os)
78        && !os_compatible(platform, os)
79    {
80        fired.push(Check::InconsistentHints);
81    }
82
83    if known.ip_prefix != current.ip_prefix {
84        if differs(known.country.as_ref(), current.country.as_ref()) {
85            fired.push(Check::CountryChanged);
86        } else if differs(known.asn.as_ref(), current.asn.as_ref()) {
87            fired.push(Check::AsnChanged);
88        } else {
89            fired.push(Check::IpPrefixChanged);
90        }
91
92        let returned = baseline.previous_ip_prefix.as_deref() == Some(current.ip_prefix.as_str());
93        let recent = baseline
94            .ip_changed_at
95            .is_some_and(|at| now.signed_duration_since(at) <= ping_pong_window);
96        if returned && recent {
97            fired.push(Check::IpPingPong);
98        }
99    }
100
101    let version_changed = matches!((known.browser_major, current.browser_major), (Some(a), Some(b)) if a != b);
102    if !version_changed && differs(known.tls.as_ref(), current.tls.as_ref()) {
103        fired.push(Check::TlsChanged);
104    }
105
106    if differs(known.accept_language.as_ref(), current.accept_language.as_ref()) {
107        fired.push(Check::AcceptLanguageChanged);
108    }
109
110    let mut signals = Vec::with_capacity(fired.len());
111    let mut score = 0u32;
112    for check in Check::ALL {
113        if fired.contains(&check)
114            && let Some(weight) = checks.weight_of(check)
115        {
116            signals.push(check);
117            score = score.saturating_add(weight);
118        }
119    }
120    Assessment { score, signals }
121}
122
123/// Known and now different, or known and now missing.
124fn differs<T: PartialEq>(known: Option<&T>, current: Option<&T>) -> bool {
125    match (known, current) {
126        (Some(a), Some(b)) => a != b,
127        (Some(_), None) => true,
128        (None, _) => false,
129    }
130}
131
132fn lost<T>(known: Option<&T>, current: Option<&T>) -> bool {
133    known.is_some() && current.is_none()
134}
135
136#[cfg(test)]
137mod tests {
138    use proptest::prelude::*;
139
140    use super::*;
141
142    fn chrome_on_windows() -> Fingerprint {
143        Fingerprint {
144            ip_prefix: "203.0.113.0/24".to_owned(),
145            country: Some("BE".to_owned()),
146            asn: Some(5432),
147            browser: Some("Chrome".to_owned()),
148            browser_major: Some(141),
149            os: Some("Windows".to_owned()),
150            ch_platform: Some("Windows".to_owned()),
151            ch_mobile: Some(false),
152            accept_language: Some("nl-be,nl,en".to_owned()),
153            tls: Some("t13d1516h2_8daaf6152771_02713d6af862".to_owned()),
154        }
155    }
156
157    fn assess_now(baseline: &Baseline, current: &Fingerprint) -> Assessment {
158        assess(baseline, current, Utc::now(), TimeDelta::minutes(5), &Checks::default())
159    }
160
161    fn decide(current: &Fingerprint) -> (Decision, Vec<Check>) {
162        let a = assess_now(&Baseline::new(chrome_on_windows()), current);
163        (a.decision(&Thresholds::default()), a.signals)
164    }
165
166    #[test]
167    fn identical_request_is_allowed() {
168        assert_eq!(decide(&chrome_on_windows()), (Decision::Allow, vec![]));
169    }
170
171    // Legitimate users: none of these may step up or revoke.
172
173    #[test]
174    fn network_switch_within_same_provider_is_allowed() {
175        let mut current = chrome_on_windows();
176        current.ip_prefix = "203.0.200.0/24".to_owned();
177        assert_eq!(decide(&current), (Decision::Allow, vec![Check::IpPrefixChanged]));
178    }
179
180    #[test]
181    fn wifi_to_mobile_data_is_allowed() {
182        let mut current = chrome_on_windows();
183        current.ip_prefix = "198.51.100.0/24".to_owned();
184        current.asn = Some(6848);
185        assert_eq!(decide(&current), (Decision::Allow, vec![Check::AsnChanged]));
186    }
187
188    #[test]
189    fn browser_update_is_allowed_even_though_tls_changes() {
190        let mut current = chrome_on_windows();
191        current.browser_major = Some(142);
192        current.tls = Some("t13d1517h2_8daaf6152771_b0da82dd1658".to_owned());
193        assert_eq!(decide(&current), (Decision::Allow, vec![]));
194    }
195
196    #[test]
197    fn language_tweak_is_allowed() {
198        let mut current = chrome_on_windows();
199        current.accept_language = Some("en".to_owned());
200        assert_eq!(decide(&current).0, Decision::Allow);
201    }
202
203    #[test]
204    fn android_desktop_mode_toggle_steps_up_not_revokes() {
205        let mut fp = chrome_on_windows();
206        fp.os = Some("Android".to_owned());
207        fp.ch_platform = Some("Android".to_owned());
208        fp.ch_mobile = Some(true);
209        let mut current = fp.clone();
210        current.os = Some("Linux".to_owned());
211        current.ch_mobile = Some(false);
212        let a = assess_now(&Baseline::new(fp), &current);
213        assert_eq!(a.signals, vec![Check::MobileHintChanged]);
214        assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
215    }
216
217    #[test]
218    fn travel_abroad_steps_up_not_revokes() {
219        let mut current = chrome_on_windows();
220        current.ip_prefix = "192.0.2.0/24".to_owned();
221        current.country = Some("FR".to_owned());
222        current.asn = Some(3215);
223        assert_eq!(decide(&current), (Decision::StepUp, vec![Check::CountryChanged]));
224    }
225
226    #[test]
227    fn baseline_without_hints_ignores_hints() {
228        let mut firefox = chrome_on_windows();
229        firefox.browser = Some("Firefox".to_owned());
230        firefox.ch_platform = None;
231        firefox.ch_mobile = None;
232        let a = assess_now(&Baseline::new(firefox.clone()), &firefox);
233        assert_eq!(a.score, 0);
234    }
235
236    // Theft.
237
238    #[test]
239    fn different_operating_system_is_revoked() {
240        let mut current = chrome_on_windows();
241        current.os = Some("macOS".to_owned());
242        current.ch_platform = Some("macOS".to_owned());
243        assert_eq!(decide(&current).0, Decision::Revoke);
244    }
245
246    #[test]
247    fn different_browser_is_revoked() {
248        let mut current = chrome_on_windows();
249        current.browser = Some("Firefox".to_owned());
250        current.ch_platform = None;
251        current.ch_mobile = None;
252        assert_eq!(decide(&current).0, Decision::Revoke);
253    }
254
255    #[test]
256    fn empty_user_agent_cannot_bypass_checks() {
257        let mut current = chrome_on_windows();
258        current.browser = None;
259        current.browser_major = None;
260        current.os = None;
261        current.ch_platform = None;
262        current.ch_mobile = None;
263        let (decision, signals) = decide(&current);
264        assert!(signals.contains(&Check::UserAgentMissing));
265        assert!(signals.contains(&Check::ClientHintsMissing));
266        assert_eq!(decision, Decision::Revoke);
267    }
268
269    #[test]
270    fn dropping_only_the_mobile_hint_is_noticed() {
271        let mut current = chrome_on_windows();
272        current.ch_mobile = None;
273        assert_eq!(decide(&current).1, vec![Check::ClientHintsMissing]);
274    }
275
276    #[test]
277    fn stripping_soft_headers_never_lowers_the_score() {
278        let mut current = chrome_on_windows();
279        current.accept_language = None;
280        current.tls = None;
281        assert_eq!(
282            decide(&current).1,
283            vec![Check::TlsChanged, Check::AcceptLanguageChanged]
284        );
285
286        let mut current = chrome_on_windows();
287        current.ip_prefix = "198.51.100.0/24".to_owned();
288        current.country = None;
289        assert_eq!(decide(&current).1, vec![Check::CountryChanged]);
290    }
291
292    #[test]
293    fn copied_user_agent_from_script_in_same_country_is_revoked() {
294        let mut current = chrome_on_windows();
295        current.ip_prefix = "198.51.100.0/24".to_owned();
296        current.asn = Some(9999);
297        current.ch_platform = None;
298        current.ch_mobile = None;
299        current.tls = Some("t13d311200_e8f1e7e78f70_d41ae481755e".to_owned());
300        current.accept_language = None;
301        assert_eq!(decide(&current).0, Decision::Revoke);
302    }
303
304    #[test]
305    fn spoofed_ua_contradicting_client_hints_is_flagged() {
306        let mut current = chrome_on_windows();
307        current.ch_platform = Some("Linux".to_owned());
308        let (decision, signals) = decide(&current);
309        assert_eq!(signals, vec![Check::PlatformHintChanged, Check::InconsistentHints]);
310        assert_eq!(decision, Decision::Revoke);
311    }
312
313    #[test]
314    fn alternating_between_two_networks_adds_ping_pong() {
315        let now = Utc::now();
316        let mut baseline = Baseline::new(chrome_on_windows());
317        let mut elsewhere = chrome_on_windows();
318        elsewhere.ip_prefix = "198.51.100.0/24".to_owned();
319        elsewhere.asn = Some(9999);
320        baseline.absorb(&elsewhere, now, &Checks::default());
321
322        let back = chrome_on_windows();
323        let soon = assess(
324            &baseline,
325            &back,
326            now + TimeDelta::seconds(20),
327            TimeDelta::minutes(5),
328            &Checks::default(),
329        );
330        assert_eq!(soon.signals, vec![Check::IpPingPong, Check::AsnChanged]);
331        // Bouncing between two ASNs within minutes looks like two parties sharing one cookie.
332        assert_eq!(soon.decision(&Thresholds::default()), Decision::StepUp);
333
334        let later = assess(
335            &baseline,
336            &back,
337            now + TimeDelta::minutes(10),
338            TimeDelta::minutes(5),
339            &Checks::default(),
340        );
341        assert_eq!(later.signals, vec![Check::AsnChanged]);
342    }
343
344    #[test]
345    fn downgrade_is_flagged() {
346        let mut current = chrome_on_windows();
347        current.browser_major = Some(120);
348        assert_eq!(decide(&current), (Decision::StepUp, vec![Check::BrowserDowngraded]));
349    }
350
351    #[test]
352    fn disabled_checks_and_custom_weights() {
353        let mut current = chrome_on_windows();
354        current.os = Some("macOS".to_owned());
355        let checks = Checks::default()
356            .disable(Check::OsChanged)
357            .weight(Check::InconsistentHints, 5);
358        let a = assess(
359            &Baseline::new(chrome_on_windows()),
360            &current,
361            Utc::now(),
362            TimeDelta::minutes(5),
363            &checks,
364        );
365        assert_eq!(a.signals, vec![Check::InconsistentHints]);
366        assert_eq!(a.score, 5);
367        let none = assess(
368            &Baseline::new(chrome_on_windows()),
369            &current,
370            Utc::now(),
371            TimeDelta::minutes(5),
372            &Checks::none(),
373        );
374        assert_eq!(none, Assessment::default());
375    }
376
377    /// Changes legitimate users make routinely, one at a time.
378    fn routine_change() -> impl Strategy<Value = (u8, u32)> {
379        (0u8..8, any::<u32>())
380    }
381
382    fn apply_routine(fp: &mut Fingerprint, kind: u8, n: u32) {
383        match kind {
384            0 => fp.ip_prefix = format!("198.51.{}.0/24", n % 256),
385            1 => {
386                fp.ip_prefix = format!("100.64.{}.0/24", n % 256);
387                fp.asn = Some(n);
388            }
389            2 => {
390                fp.ip_prefix = format!("192.0.{}.0/24", n % 256);
391                fp.country = Some(["FR", "DE", "US", "JP"][(n % 4) as usize].to_owned());
392                fp.asn = Some(n);
393            }
394            3 => {
395                fp.browser_major = fp.browser_major.map(|m| m + 1 + n % 5);
396                fp.tls = Some(format!("tls-{n}"));
397            }
398            4 => fp.accept_language = Some(["en", "fr,en", "de-de,de"][(n % 3) as usize].to_owned()),
399            5 => fp.tls = Some(format!("tls-{n}")),
400            6 => fp.ch_mobile = fp.ch_mobile.map(|m| !m),
401            _ => fp.os = Some("Linux".to_owned()),
402        }
403    }
404
405    proptest! {
406        #[test]
407        fn no_single_routine_change_revokes(
408            (kind, n) in routine_change(),
409            android in any::<bool>(),
410            previous in any::<bool>(),
411            secs in 0i64..600,
412        ) {
413            let mut fp = chrome_on_windows();
414            if android {
415                fp.os = Some("Android".into());
416                fp.ch_platform = Some("Android".into());
417                fp.ch_mobile = Some(true);
418            }
419            if !android && kind == 7 {
420                // Linux is only a routine change for Android devices.
421                return Ok(());
422            }
423            let now = Utc::now();
424            let mut baseline = Baseline::new(fp.clone());
425            let mut current = fp.clone();
426            apply_routine(&mut current, kind, n);
427            if previous {
428                baseline.previous_ip_prefix = Some(current.ip_prefix.clone());
429                baseline.ip_changed_at = Some(now - TimeDelta::seconds(secs));
430            }
431            let a = assess(&baseline, &current, now, TimeDelta::minutes(5), &Checks::default());
432            prop_assert_ne!(a.decision(&Thresholds::default()), Decision::Revoke, "{:?}", a);
433        }
434
435        #[test]
436        fn score_is_sum_of_signal_weights(
437            os in proptest::option::of("[A-Za-z]{1,8}"),
438            browser in proptest::option::of("[A-Za-z]{1,8}"),
439            major in proptest::option::of(0u32..300),
440            platform in proptest::option::of("[A-Za-z]{1,8}"),
441            mobile in proptest::option::of(any::<bool>()),
442            lang in proptest::option::of("[a-z]{2}"),
443        ) {
444            let current = Fingerprint {
445                os, browser, browser_major: major, ch_platform: platform, ch_mobile: mobile,
446                accept_language: lang, ..chrome_on_windows()
447            };
448            let a = assess(&Baseline::new(chrome_on_windows()), &current, Utc::now(), TimeDelta::minutes(5), &Checks::default());
449            let sum: u32 = a.signals.iter().map(|c| c.default_weight()).sum();
450            prop_assert_eq!(a.score, sum);
451        }
452    }
453}