Skip to main content

sofie_http/shield/
rule.rs

1use std::time::Duration;
2
3use sofie_core::{Error, Result};
4
5/// Each counted per caller key in a sliding window of its own.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
7pub(crate) enum Rule {
8    SignIn,
9    /// Locks signing in to the account for the failing caller.
10    Account,
11    /// Locks signing in to the account for everyone, so a looser count.
12    AccountTotal,
13    SignUp,
14    Scan,
15    /// As guessing other owners' ids is.
16    Probe,
17    Refused,
18    Malformed,
19    Throttled,
20    Protocol,
21}
22
23/// Signals before the caller is slowed down; twice as many block, half as many are reported.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub(crate) struct Limit {
26    pub(crate) count: u32,
27    pub(crate) window: Duration,
28}
29
30impl Rule {
31    pub(crate) const ALL: [Self; 10] = [
32        Self::SignIn,
33        Self::Account,
34        Self::AccountTotal,
35        Self::SignUp,
36        Self::Scan,
37        Self::Probe,
38        Self::Refused,
39        Self::Malformed,
40        Self::Throttled,
41        Self::Protocol,
42    ];
43
44    #[must_use]
45    pub(crate) fn name(self) -> &'static str {
46        match self {
47            Self::SignIn => "sign_in",
48            Self::Account => "account",
49            Self::AccountTotal => "account_total",
50            Self::SignUp => "sign_up",
51            Self::Scan => "scan",
52            Self::Probe => "probe",
53            Self::Refused => "refused",
54            Self::Malformed => "malformed",
55            Self::Throttled => "throttled",
56            Self::Protocol => "protocol",
57        }
58    }
59
60    pub(crate) fn reason(self) -> &'static str {
61        match self {
62            Self::SignIn => "failed sign-ins from one caller",
63            Self::Account => "failed sign-ins to one account from one caller",
64            Self::AccountTotal => "failed sign-ins to one account from every caller",
65            Self::SignUp => "a burst of sign-ups from one caller",
66            Self::Scan => "requests for paths no route serves",
67            Self::Probe => "records not found on matched routes",
68            Self::Refused => "cross-site, unsigned or unpermitted requests",
69            Self::Malformed => "malformed or oversized bodies",
70            Self::Throttled => "requests repeated over a rate limit",
71            Self::Protocol => "connections failed on protocol errors",
72        }
73    }
74
75    pub(crate) fn named(name: &str) -> Option<Self> {
76        Self::ALL.into_iter().find(|rule| rule.name() == name)
77    }
78
79    /// Generous enough that mistyping, a stale bookmark or a flaky connection stays far below.
80    fn default_limit(self) -> Limit {
81        let (count, seconds) = match self {
82            Self::SignIn => (20, 600),
83            Self::Account => (10, 900),
84            Self::AccountTotal => (100, 900),
85            Self::SignUp => (10, 3600),
86            Self::Scan | Self::Throttled => (30, 60),
87            Self::Probe | Self::Malformed => (20, 60),
88            Self::Refused => (10, 60),
89            Self::Protocol => (5, 60),
90        };
91        Limit {
92            count,
93            window: Duration::from_secs(seconds),
94        }
95    }
96}
97
98/// `SOFIE_SHIELD_THRESHOLDS`: `scan=60, member.probe=40`; an area's own count wins.
99#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct Thresholds {
101    counts: Vec<(Option<String>, Rule, u32)>,
102}
103
104impl Thresholds {
105    pub fn parse(text: &str) -> Result<Self> {
106        let counts = text
107            .split(',')
108            .map(str::trim)
109            .filter(|entry| !entry.is_empty())
110            .map(threshold)
111            .collect::<Result<_>>()?;
112        Ok(Self { counts })
113    }
114
115    pub(crate) fn limit(&self, area: Option<&str>, rule: Rule) -> Limit {
116        let set = |wanted: Option<&str>| {
117            self.counts
118                .iter()
119                .find(|(area, named, _)| *named == rule && area.as_deref() == wanted)
120                .map(|(_, _, count)| *count)
121        };
122        let default = rule.default_limit();
123        let count = area
124            .and_then(|area| set(Some(area)))
125            .or_else(|| set(None))
126            .unwrap_or(default.count);
127        Limit { count, ..default }
128    }
129
130    pub(crate) fn check_areas(&self, areas: &[&str]) -> Result<()> {
131        match self
132            .counts
133            .iter()
134            .filter_map(|(area, ..)| area.as_deref())
135            .find(|area| !areas.contains(area))
136        {
137            Some(unknown) => Err(Error::internal_message(format!(
138                "shield threshold for area `{unknown}`, which the app does not have"
139            ))),
140            None => Ok(()),
141        }
142    }
143}
144
145fn threshold(entry: &str) -> Result<(Option<String>, Rule, u32)> {
146    let refused = || {
147        Error::internal_message(format!(
148            "shield threshold `{entry}` is not `rule=count` or `area.rule=count` with a count above 0"
149        ))
150    };
151    let (name, count) = entry.split_once('=').ok_or_else(refused)?;
152    let count: u32 = count.trim().parse().map_err(|_| refused())?;
153    let (area, rule) = match name.trim().rsplit_once('.') {
154        Some((area, rule)) => (Some(area.to_owned()), rule),
155        None => (None, name.trim()),
156    };
157    let rule = Rule::named(rule).ok_or_else(refused)?;
158    if count == 0 {
159        return Err(refused());
160    }
161    Ok((area, rule, count))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn an_area_count_beats_the_general_one_which_beats_the_default() {
170        let thresholds = Thresholds::parse("scan=60, member.scan=90, probe = 5").unwrap();
171
172        assert_eq!(thresholds.limit(Some("member"), Rule::Scan).count, 90);
173        assert_eq!(thresholds.limit(Some("web"), Rule::Scan).count, 60);
174        assert_eq!(thresholds.limit(None, Rule::Probe).count, 5);
175        assert_eq!(
176            thresholds.limit(None, Rule::SignIn),
177            Rule::SignIn.default_limit()
178        );
179    }
180
181    #[test]
182    fn a_malformed_entry_or_an_unknown_area_is_refused() {
183        for entry in ["scan", "scan=0", "scan=-1", "scanning=3", "web.=3"] {
184            let error = Thresholds::parse(entry).unwrap_err();
185            assert!(error.to_string().contains(entry), "{error}");
186        }
187        let thresholds = Thresholds::parse("console.probe=3").unwrap();
188
189        assert!(thresholds.check_areas(&["console"]).is_ok());
190        assert!(thresholds.check_areas(&["web"]).is_err());
191    }
192}