Skip to main content

sofie_http/shield/
mod.rs

1//! Attacks told apart by behaviour, never payload: requests only send signals to a detector thread.
2
3mod arguments;
4mod blocks;
5mod detector;
6mod effects;
7mod report;
8mod rule;
9mod window;
10
11use std::net::IpAddr;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::mpsc::{self, SyncSender};
15use std::time::Duration;
16
17use http::StatusCode;
18use sofie_core::{Clock, Error, Result, Uuid};
19use sofie_db::Db;
20
21pub use arguments::{ActiveBlock, ActiveBlocks, ShieldBlocks, SignIns};
22pub(crate) use detector::Safety;
23pub use rule::Thresholds;
24
25use self::blocks::{Blocks, Grade, Key};
26use self::detector::{Detector, Message, Signal};
27use self::effects::Effects;
28use self::rule::Rule;
29use crate::answer::Response;
30use crate::pipeline::Asked;
31use crate::throttle::{Limiter, Rate, Requester};
32use crate::trace::RouteLabel;
33
34/// Past it a signal is dropped and counted rather than making a request wait.
35const QUEUE: usize = 16_384;
36const SLOW_RATE: Rate = Rate::per_second(1);
37/// Asked for by browsers and crawlers on their own, so missing ones are no scan.
38const WELL_KNOWN: [&str; 6] = [
39    "/favicon.ico",
40    "/robots.txt",
41    "/sitemap.xml",
42    "/.well-known/security.txt",
43    "/.well-known/change-password",
44    "/.well-known/traffic-advice",
45];
46
47/// `SOFIE_SHIELD`: `on`, `dry-run`, `off`.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum ShieldMode {
50    Enforce,
51    DryRun,
52    /// For tests, `sofie bench` and `sofie fuzz`, whose traffic looks like an attack.
53    Off,
54}
55
56impl ShieldMode {
57    pub fn parse(text: &str) -> Result<Self> {
58        match text.trim() {
59            "on" => Ok(Self::Enforce),
60            "dry-run" => Ok(Self::DryRun),
61            "off" => Ok(Self::Off),
62            other => Err(Error::internal_message(format!(
63                "shield mode `{other}` is not `on`, `dry-run` or `off`"
64            ))),
65        }
66    }
67
68    /// A development build only reports, so a developer's mistakes lock nobody out.
69    fn of_build() -> Self {
70        if cfg!(debug_assertions) {
71            Self::DryRun
72        } else {
73            Self::Enforce
74        }
75    }
76}
77
78/// `sofie::run` reads it from `SOFIE_SHIELD`, `SOFIE_SHIELD_THRESHOLDS` and `SOFIE_SHIELD_SHARED`.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct Shield {
81    mode: ShieldMode,
82    thresholds: Thresholds,
83    shared: bool,
84}
85
86impl Default for Shield {
87    fn default() -> Self {
88        Self::new(ShieldMode::of_build())
89    }
90}
91
92impl Shield {
93    #[must_use]
94    pub fn new(mode: ShieldMode) -> Self {
95        Self {
96            mode,
97            thresholds: Thresholds::default(),
98            shared: false,
99        }
100    }
101
102    #[must_use]
103    pub fn thresholds(self, thresholds: Thresholds) -> Self {
104        Self { thresholds, ..self }
105    }
106
107    #[must_use]
108    pub fn shared(self) -> Self {
109        Self {
110            shared: true,
111            ..self
112        }
113    }
114}
115
116#[derive(Clone, Default)]
117pub(crate) struct Guard(Option<Arc<Sentinel>>);
118
119pub(crate) struct Sentinel {
120    blocks: Arc<Blocks>,
121    sender: SyncSender<Message>,
122    dropped: Arc<AtomicU64>,
123    clock: Clock,
124    slowed: Limiter,
125    shared: Option<Db>,
126}
127
128/// The shield's own refusal is no signal of its own.
129#[derive(Clone, Copy)]
130struct Shielded;
131
132#[derive(Clone, Copy)]
133struct SignedIn(Uuid);
134
135impl Guard {
136    pub(crate) fn start(
137        app: &mut crate::App,
138        background: &mut crate::background::Background,
139    ) -> Result<Self> {
140        let shield = std::mem::take(&mut app.serving.shield);
141        if shield.mode == ShieldMode::Off {
142            return Ok(Self(None));
143        }
144        let areas: Vec<&str> = app.routes.iter().map(|route| route.audience.name).collect();
145        shield.thresholds.check_areas(&areas)?;
146        let runtime = tokio::runtime::Handle::try_current()
147            .map_err(|error| Error::internal("starting the shield outside a runtime", error))?;
148        let db = app.db.clone();
149        let effects = Effects {
150            runtime: runtime.clone(),
151            db: db.clone(),
152            members: app.members.clone(),
153            shared: shield.shared,
154        };
155        let dry_run = shield.mode == ShieldMode::DryRun;
156        let safety = Safety {
157            #[cfg(feature = "trusted-proxies")]
158            proxies: app.serving.proxies.clone(),
159        };
160        let detector = Detector::new(shield.thresholds, safety, dry_run, Some(effects));
161        let (blocks, dropped) = (Arc::clone(&detector.blocks), Arc::clone(&detector.dropped));
162        if shield.shared {
163            background.spawn(effects::follow(
164                db.clone(),
165                db.clock(),
166                Arc::downgrade(&blocks),
167            ));
168        }
169        let (sender, receiver) = mpsc::sync_channel(QUEUE);
170        detector::start(receiver, detector, db.clock())?;
171        Ok(Self(Some(Arc::new(Sentinel {
172            blocks,
173            sender,
174            dropped,
175            clock: db.clock(),
176            slowed: Limiter::new(SLOW_RATE),
177            shared: shield.shared.then_some(db),
178        }))))
179    }
180
181    /// By principal when signed in, so an account is not held to what its neighbours did, but
182    /// for the sign-ins and sign-ups counted by address: any session cookie would lift those.
183    pub(crate) fn refusal(&self, asked: Asked<'_>, principal: Option<Uuid>) -> Option<Response> {
184        let sentinel = self.0.as_ref()?;
185        if sentinel.blocks.is_empty() {
186            return None;
187        }
188        let now = sentinel.clock.now().timestamp();
189        let requester = Requester::of(principal, asked.client);
190        let address = Requester::of(None, asked.client);
191        let (requester, block) = sentinel
192            .blocks
193            .find(Key::Caller(requester), now.as_microsecond())
194            .map(|block| (requester, block))
195            .or_else(|| {
196                let block = sentinel
197                    .blocks
198                    .find(Key::Caller(address), now.as_microsecond())?;
199                matches!(block.rule, Rule::SignIn | Rule::SignUp).then_some((address, block))
200            })?;
201        let wait = match block.grade {
202            Grade::Slow => sentinel.slowed.admit(requester, now).err()?,
203            Grade::Block => Duration::from_micros(
204                u64::try_from(block.until - now.as_microsecond()).unwrap_or_default(),
205            ),
206            Grade::Observe | Grade::Lock => return None,
207        };
208        let mut response = crate::answer::failure(Error::TooManyRequests(wait), asked.caller);
209        response.extensions_mut().insert(Shielded);
210        Some(response)
211    }
212
213    pub(crate) fn observe(
214        &self,
215        asked: Asked<'_>,
216        route: Option<RouteLabel<'_>>,
217        response: &Response,
218    ) {
219        let Some(sentinel) = &self.0 else {
220            return;
221        };
222        let area = route.and_then(|route| route.area);
223        let Some(rule) = rule_of(response.status(), area.is_some()) else {
224            return;
225        };
226        if rule == Rule::Scan && is_well_known(asked.request.uri.path()) {
227            return;
228        }
229        if response.extensions().get::<Shielded>().is_some() {
230            return;
231        }
232        let principal = response
233            .extensions()
234            .get::<SignedIn>()
235            .map(|signed| signed.0);
236        let key = Key::Caller(Requester::of(principal, asked.client));
237        sentinel.send(rule, key, asked.client, area);
238    }
239
240    pub(crate) fn connection_failed(&self, peer: IpAddr) {
241        if let Some(sentinel) = &self.0 {
242            let key = Key::Caller(Requester::of(None, peer));
243            sentinel.send(Rule::Protocol, key, peer, None);
244        }
245    }
246
247    pub(crate) fn for_request(
248        &self,
249        asked: Asked<'_>,
250        area: &'static str,
251    ) -> Option<RequestShield> {
252        Some(RequestShield {
253            sentinel: Arc::clone(self.0.as_ref()?),
254            address: asked.client,
255            area,
256        })
257    }
258}
259
260impl Sentinel {
261    fn send(&self, rule: Rule, key: Key, address: IpAddr, area: Option<&'static str>) {
262        let signal = Signal {
263            rule,
264            key,
265            address,
266            area,
267            at: self.now(),
268        };
269        self.post(Message::Signal(signal));
270    }
271
272    fn post(&self, message: Message) {
273        if self.sender.try_send(message).is_err() {
274            self.dropped.fetch_add(1, Ordering::Relaxed);
275        }
276    }
277
278    fn now(&self) -> i64 {
279        self.clock.now().timestamp().as_microsecond()
280    }
281}
282
283#[derive(Clone)]
284pub(crate) struct RequestShield {
285    sentinel: Arc<Sentinel>,
286    address: IpAddr,
287    area: &'static str,
288}
289
290/// Read back by [`Guard::observe`] once the response leaves the session.
291pub(crate) fn tag(mut response: Response, principal: Option<Uuid>) -> Response {
292    if let Some(principal) = principal
293        && rule_of(response.status(), true).is_some()
294    {
295        response.extensions_mut().insert(SignedIn(principal));
296    }
297    response
298}
299
300/// Safari also asks for sized icons, such as `/apple-touch-icon-120x120-precomposed.png`.
301fn is_well_known(path: &str) -> bool {
302    WELL_KNOWN.contains(&path)
303        || path
304            .strip_prefix("/apple-touch-icon")
305            .and_then(|rest| rest.strip_suffix(".png"))
306            .is_some_and(|rest| !rest.contains('/'))
307}
308
309/// On a matched route a missing record is a probe, anywhere else a scan.
310fn rule_of(status: StatusCode, matched: bool) -> Option<Rule> {
311    match status {
312        StatusCode::NOT_FOUND if matched => Some(Rule::Probe),
313        StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => Some(Rule::Scan),
314        StatusCode::FORBIDDEN => Some(Rule::Refused),
315        StatusCode::BAD_REQUEST | StatusCode::PAYLOAD_TOO_LARGE => Some(Rule::Malformed),
316        StatusCode::TOO_MANY_REQUESTS => Some(Rule::Throttled),
317        _ => None,
318    }
319}
320
321impl crate::Serving {
322    #[must_use]
323    pub fn shield(mut self, shield: Shield) -> Self {
324        self.shield = shield;
325        self
326    }
327}
328
329#[cfg(test)]
330mod tests;