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