Sign-in, 2FA codes, device codes and reset requests slow guessing aa0bfbd11f02
Failures count per account (5 free) and per client address (20 free); then each doubles the wait, from 2 seconds to a 15-minute lockout, and the page says when to try again. A success clears the account's count, a quiet hour forgets. Each wait that starts is audited with the address. Closes #221
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-15 23:49 UTC
- Signature
- ✔ signed by Claude Code
- Hash
aa0bfbd11f02d9df4d579f4e8b0f0890d73629bc857878c414e882ebc42d3e85- Depends on
- 19bb31ff1f5c 2a83f5303458 34beb1a7e376 7d2eb590051f 93be9ce4653e 9a777fec4ed9 9a8e3b4419e0 a46d15e72101 b64e2537d702 bff9106edcba d23464c0ac60 df71c7892d27
- Depended on by
- 33d98147511b 8dcaeeb848c6 965b950f9b96 c8684d44d549 3dd6582de060 d74ce22fa80e
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 21 atoms, 1 tree operation, 8 files
Browse the tree at this change
added crates/hub/src/throttle.rs +204 −0
| 1 | + | //! Slowing down guessing (oxygenhub#221): password sign-in, the 2FA code |
|
| 2 | + | //! step, password reset requests and device codes. |
|
| 3 | + | //! |
|
| 4 | + | //! Each key (an account or an address, within one action) counts its |
|
| 5 | + | //! failures. A few are free; after that each one doubles the wait before the |
|
| 6 | + | //! next try, up to a short lockout. A quiet hour forgets the count, and a |
|
| 7 | + | //! success clears the account's. Kept in memory: a restart forgets it, which |
|
| 8 | + | //! costs an attacker a restart they cannot cause. |
|
| 9 | + | ||
| 10 | + | use std::collections::HashMap; |
|
| 11 | + | ||
| 12 | + | /// Failures an account may have before waits start. |
|
| 13 | + | const ACCOUNT_FREE: u32 = 5; |
|
| 14 | + | /// Failures an address may have before waits start: higher, since many |
|
| 15 | + | /// people can share one address. |
|
| 16 | + | const ADDRESS_FREE: u32 = 20; |
|
| 17 | + | /// The first wait. |
|
| 18 | + | const FIRST_WAIT_MS: i64 = 2_000; |
|
| 19 | + | /// The longest wait: the lockout. |
|
| 20 | + | pub const LOCKOUT_MS: i64 = 15 * 60_000; |
|
| 21 | + | /// A key with no failure for this long starts again. |
|
| 22 | + | const FORGET_MS: i64 = 60 * 60_000; |
|
| 23 | + | /// Keys kept before stale ones are swept. |
|
| 24 | + | const SWEEP_AT: usize = 50_000; |
|
| 25 | + | ||
| 26 | + | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] |
|
| 27 | + | pub enum Kind { |
|
| 28 | + | Account, |
|
| 29 | + | Address, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /// What is counted: an account or an address, within one action. |
|
| 33 | + | #[derive(Clone, PartialEq, Eq, Hash, Debug)] |
|
| 34 | + | pub struct Key { |
|
| 35 | + | action: &'static str, |
|
| 36 | + | kind: Kind, |
|
| 37 | + | id: String, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | impl Key { |
|
| 41 | + | /// An account, by name; names compare without case. |
|
| 42 | + | pub fn account(action: &'static str, name: &str) -> Key { |
|
| 43 | + | Key { action, kind: Kind::Account, id: name.trim().to_lowercase() } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// An address. An empty one (unknown) is never counted. |
|
| 47 | + | pub fn address(action: &'static str, addr: &str) -> Key { |
|
| 48 | + | Key { action, kind: Kind::Address, id: addr.to_string() } |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | fn free(&self) -> u32 { |
|
| 52 | + | match self.kind { |
|
| 53 | + | Kind::Account => ACCOUNT_FREE, |
|
| 54 | + | Kind::Address => ADDRESS_FREE, |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[derive(Default)] |
|
| 60 | + | struct Count { |
|
| 61 | + | failures: u32, |
|
| 62 | + | last_ms: i64, |
|
| 63 | + | until_ms: i64, |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | #[derive(Default)] |
|
| 67 | + | pub struct Throttle { |
|
| 68 | + | counts: HashMap<Key, Count>, |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | impl Throttle { |
|
| 72 | + | /// How long, in milliseconds, before any of these keys may try again; |
|
| 73 | + | /// `None` when all may try now. |
|
| 74 | + | pub fn wait(&self, keys: &[Key], now_ms: i64) -> Option<i64> { |
|
| 75 | + | keys.iter() |
|
| 76 | + | .filter_map(|k| self.counts.get(k)) |
|
| 77 | + | .filter(|c| now_ms - c.last_ms < FORGET_MS) |
|
| 78 | + | .map(|c| c.until_ms - now_ms) |
|
| 79 | + | .filter(|w| *w > 0) |
|
| 80 | + | .max() |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /// Count a failure (or, for an action with no failure, an attempt) |
|
| 84 | + | /// against each key; the wait it leaves, if any. |
|
| 85 | + | pub fn failed(&mut self, keys: &[Key], now_ms: i64) -> Option<i64> { |
|
| 86 | + | if self.counts.len() >= SWEEP_AT { |
|
| 87 | + | self.counts.retain(|_, c| now_ms - c.last_ms < FORGET_MS); |
|
| 88 | + | } |
|
| 89 | + | for key in keys.iter().filter(|k| !k.id.is_empty()) { |
|
| 90 | + | let count = self.counts.entry(key.clone()).or_default(); |
|
| 91 | + | if now_ms - count.last_ms >= FORGET_MS { |
|
| 92 | + | *count = Count::default(); |
|
| 93 | + | } |
|
| 94 | + | count.failures += 1; |
|
| 95 | + | count.last_ms = now_ms; |
|
| 96 | + | if count.failures > key.free() { |
|
| 97 | + | let doublings = (count.failures - key.free() - 1).min(20); |
|
| 98 | + | count.until_ms = now_ms + (FIRST_WAIT_MS << doublings).min(LOCKOUT_MS); |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | self.wait(keys, now_ms) |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// A success: the key starts again. |
|
| 105 | + | pub fn cleared(&mut self, key: &Key) { |
|
| 106 | + | self.counts.remove(key); |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// "Too many tries: try again in 2 minutes." for a wait. |
|
| 111 | + | pub fn message(wait_ms: i64) -> String { |
|
| 112 | + | let secs = (wait_ms + 999) / 1000; |
|
| 113 | + | let when = match secs { |
|
| 114 | + | ..=1 => "a second".to_string(), |
|
| 115 | + | 2..=59 => format!("{secs} seconds"), |
|
| 116 | + | _ => { |
|
| 117 | + | let mins = (secs + 59) / 60; |
|
| 118 | + | if mins == 1 { |
|
| 119 | + | "a minute".to_string() |
|
| 120 | + | } else { |
|
| 121 | + | format!("{mins} minutes") |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | }; |
|
| 125 | + | format!("Too many tries: try again in {when}.") |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | #[cfg(test)] |
|
| 129 | + | mod tests { |
|
| 130 | + | use super::*; |
|
| 131 | + | ||
| 132 | + | const T0: i64 = 1_000_000_000; |
|
| 133 | + | ||
| 134 | + | #[test] |
|
| 135 | + | fn an_account_gets_a_few_free_failures_then_doubling_waits_up_to_the_lockout() { |
|
| 136 | + | let mut t = Throttle::default(); |
|
| 137 | + | let k = [Key::account("login", "Alice")]; |
|
| 138 | + | for _ in 0..ACCOUNT_FREE { |
|
| 139 | + | assert_eq!(t.wait(&k, T0), None); |
|
| 140 | + | t.failed(&k, T0); |
|
| 141 | + | } |
|
| 142 | + | assert_eq!(t.wait(&k, T0), None, "the free failures cost nothing"); |
|
| 143 | + | assert_eq!(t.failed(&k, T0), Some(2_000), "the failure says the wait it starts"); |
|
| 144 | + | t.failed(&k, T0); |
|
| 145 | + | assert_eq!(t.wait(&k, T0), Some(4_000)); |
|
| 146 | + | for _ in 0..20 { |
|
| 147 | + | t.failed(&k, T0); |
|
| 148 | + | } |
|
| 149 | + | assert_eq!(t.wait(&k, T0), Some(LOCKOUT_MS), "capped at the lockout"); |
|
| 150 | + | assert_eq!(t.wait(&k, T0 + LOCKOUT_MS), None, "and it ends"); |
|
| 151 | + | assert_eq!(t.wait(&[Key::account("login", " alice ")], T0), Some(LOCKOUT_MS), "names compare without case"); |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | #[test] |
|
| 155 | + | fn a_success_clears_the_account_and_a_quiet_hour_forgets() { |
|
| 156 | + | let mut t = Throttle::default(); |
|
| 157 | + | let k = [Key::account("login", "bob")]; |
|
| 158 | + | for _ in 0..10 { |
|
| 159 | + | t.failed(&k, T0); |
|
| 160 | + | } |
|
| 161 | + | assert!(t.wait(&k, T0).is_some()); |
|
| 162 | + | t.cleared(&k[0]); |
|
| 163 | + | assert_eq!(t.wait(&k, T0), None); |
|
| 164 | + | ||
| 165 | + | for _ in 0..10 { |
|
| 166 | + | t.failed(&k, T0); |
|
| 167 | + | } |
|
| 168 | + | let later = T0 + FORGET_MS; |
|
| 169 | + | assert_eq!(t.wait(&k, later), None); |
|
| 170 | + | t.failed(&k, later); |
|
| 171 | + | assert_eq!(t.wait(&k, later), None, "the count started again"); |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | #[test] |
|
| 175 | + | fn addresses_get_more_room_and_actions_and_unknown_addresses_are_apart() { |
|
| 176 | + | let mut t = Throttle::default(); |
|
| 177 | + | let addr = [Key::address("login", "203.0.113.9")]; |
|
| 178 | + | for _ in 0..ADDRESS_FREE { |
|
| 179 | + | t.failed(&addr, T0); |
|
| 180 | + | } |
|
| 181 | + | assert_eq!(t.wait(&addr, T0), None); |
|
| 182 | + | t.failed(&addr, T0); |
|
| 183 | + | assert_eq!(t.wait(&addr, T0), Some(2_000)); |
|
| 184 | + | assert_eq!(t.wait(&[Key::address("reset", "203.0.113.9")], T0), None, "another action counts apart"); |
|
| 185 | + | ||
| 186 | + | let unknown = [Key::address("login", "")]; |
|
| 187 | + | for _ in 0..100 { |
|
| 188 | + | t.failed(&unknown, T0); |
|
| 189 | + | } |
|
| 190 | + | assert_eq!(t.wait(&unknown, T0), None, "an unknown address is never counted"); |
|
| 191 | + | ||
| 192 | + | // The longest wait of the keys given is the one that counts. |
|
| 193 | + | let both = [Key::account("login", "carol"), addr[0].clone()]; |
|
| 194 | + | assert_eq!(t.wait(&both, T0), Some(2_000)); |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | #[test] |
|
| 198 | + | fn the_message_says_when() { |
|
| 199 | + | assert_eq!(message(900), "Too many tries: try again in a second."); |
|
| 200 | + | assert_eq!(message(4_000), "Too many tries: try again in 4 seconds."); |
|
| 201 | + | assert_eq!(message(61_000), "Too many tries: try again in 2 minutes."); |
|
| 202 | + | assert_eq!(message(LOCKOUT_MS), "Too many tries: try again in 15 minutes."); |
|
| 203 | + | } |
|
| 204 | + | } |
modified crates/hub/src/lib.rs +4 −0
| … | 34 unchanged lines |
||
| 35 | 35 | pub mod social; |
|
| 36 | 36 | pub mod storage; |
|
| 37 | 37 | pub mod swarm; |
|
| 38 | + | pub mod throttle; |
|
| 38 | 39 | pub mod variables; |
|
| 39 | 40 | pub mod web; |
|
| 40 | 41 | pub mod wiki; |
|
| … | 21 unchanged lines |
||
| 62 | 63 | /// Which assistants are answering which proposals, and recent calls |
|
| 63 | 64 | /// per repository (hub spec §13.4). |
|
| 64 | 65 | pub assistant_gate: std::sync::Mutex<assistants::Gate>, |
|
| 66 | + | /// Failed sign-ins and the like, per account and address (oxygenhub#221). |
|
| 67 | + | pub throttle: std::sync::Mutex<throttle::Throttle>, |
|
| 65 | 68 | pub events: events::Events, |
|
| 66 | 69 | /// Who is resolving which conflict together (hub spec §6.10). |
|
| 67 | 70 | pub rooms: Arc<live::Rooms>, |
|
| … | 54 unchanged lines |
||
| 122 | 125 | secret_key, |
|
| 123 | 126 | oidc: tokio::sync::OnceCell::new(), |
|
| 124 | 127 | assistant_gate: std::sync::Mutex::new(assistants::Gate::default()), |
|
| 128 | + | throttle: std::sync::Mutex::new(throttle::Throttle::default()), |
|
| 125 | 129 | events: events::Events::default(), |
|
| 126 | 130 | rooms: Arc::new(live::Rooms::default()), |
|
| 127 | 131 | swarm: Default::default(), |
|
| … | 175 unchanged lines |
modified crates/hub/src/web/device.rs +14 −0
| … | 6 unchanged lines |
||
| 7 | 7 | use crate::api::device::normalize_user_code; |
|
| 8 | 8 | use crate::auth::Viewer; |
|
| 9 | 9 | use crate::db::{self, accounts}; |
|
| 10 | + | use crate::throttle::{self, Key}; |
|
| 10 | 11 | use crate::Shared; |
|
| 11 | 12 | use askama::Template; |
|
| 12 | 13 | use axum::extract::{Query, State}; |
|
| … | 64 unchanged lines |
||
| 77 | 78 | pub async fn answer( |
|
| 78 | 79 | State(app): State<Shared>, |
|
| 79 | 80 | viewer: Viewer, |
|
| 81 | + | headers: axum::http::HeaderMap, |
|
| 82 | + | extensions: axum::http::Extensions, |
|
| 80 | 83 | axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>, |
|
| 81 | 84 | ) -> Result<Page<DevicePage>, Error> { |
|
| 82 | 85 | let shell = Shell::new(&app, "/device", &viewer); |
|
| 83 | 86 | let me = require(&viewer, &shell)?; |
|
| 84 | 87 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 85 | 88 | let code = normalize_user_code(&f.code); |
|
| 89 | + | // Guessing codes is slowed per account and address (oxygenhub#221). |
|
| 90 | + | let addr = crate::api::client_addr(&app, &headers, &extensions); |
|
| 91 | + | let keys = [Key::account("device", &me.account.username), Key::address("device", &addr)]; |
|
| 92 | + | let now = db::now_ms(); |
|
| 93 | + | if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now) { |
|
| 94 | + | return Ok(page(shell, code, None, None, Some(throttle::message(wait)))); |
|
| 95 | + | } |
|
| 86 | 96 | let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 87 | 97 | let Some(d) = d else { |
|
| 98 | + | let started = app.throttle.lock().unwrap().failed(&keys, now); |
|
| 99 | + | if let Some(wait) = started { |
|
| 100 | + | accounts::audit(&app.db, Some(&me.account), "device.throttled", &addr, &format!("for {}s", wait / 1000)).await.ok(); |
|
| 101 | + | } |
|
| 88 | 102 | let e = "No app is waiting for that code: it may have expired. Ask the app for a new one."; |
|
| 89 | 103 | return Ok(page(shell, code, None, None, Some(e.into()))); |
|
| 90 | 104 | }; |
|
| … | 33 unchanged lines |
modified crates/hub/src/web/recovery.rs +22 −1
| … | 96 unchanged lines |
||
| 97 | 97 | } |
|
| 98 | 98 | ||
| 99 | 99 | /// `POST /login/reset`: the same answer whether or not anything matched. |
|
| 100 | - | pub async fn request(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RequestForm>) -> Result<Page<RecoveryPage>, Error> { |
|
| 100 | + | pub async fn request( |
|
| 101 | + | State(app): State<Shared>, |
|
| 102 | + | viewer: Viewer, |
|
| 103 | + | headers: axum::http::HeaderMap, |
|
| 104 | + | extensions: axum::http::Extensions, |
|
| 105 | + | Form(f): Form<RequestForm>, |
|
| 106 | + | ) -> Result<Page<RecoveryPage>, Error> { |
|
| 101 | 107 | let shell = Shell::new(&app, "/login/reset", &viewer); |
|
| 102 | 108 | if !crate::mail::enabled(&app) { |
|
| 103 | 109 | return Ok(Page(RecoveryPage::new(&app, shell, "request"))); |
|
| 110 | + | } |
|
| 111 | + | // Every request counts against its address (oxygenhub#221): the answer |
|
| 112 | + | // is the same whether or not anything matched, so this says nothing. |
|
| 113 | + | let addr = crate::api::client_addr(&app, &headers, &extensions); |
|
| 114 | + | let keys = [crate::throttle::Key::address("reset", &addr)]; |
|
| 115 | + | let now = db::now_ms(); |
|
| 116 | + | let waiting = app.throttle.lock().unwrap().wait(&keys, now); |
|
| 117 | + | if let Some(wait) = waiting { |
|
| 118 | + | let mut page = RecoveryPage::new(&app, shell, "request"); |
|
| 119 | + | page.error = Some(crate::throttle::message(wait)); |
|
| 120 | + | return Ok(Page(page)); |
|
| 121 | + | } |
|
| 122 | + | let started = app.throttle.lock().unwrap().failed(&keys, now); |
|
| 123 | + | if let Some(wait) = started { |
|
| 124 | + | accounts::audit(&app.db, None, "password.reset_throttled", &addr, &format!("for {}s", wait / 1000)).await.ok(); |
|
| 104 | 125 | } |
|
| 105 | 126 | let who = f.who.trim(); |
|
| 106 | 127 | let e = |e| Error::from_db(shell.clone(), e); |
|
| … | 95 unchanged lines |
modified crates/hub/src/web/session.rs +28 −1
| … | 2 unchanged lines |
||
| 3 | 3 | use super::{Error, Page, Shell}; |
|
| 4 | 4 | use crate::auth::{self, totp, Viewer}; |
|
| 5 | 5 | use crate::db::{self, accounts}; |
|
| 6 | + | use crate::throttle::{self, Key}; |
|
| 6 | 7 | use crate::Shared; |
|
| 7 | 8 | use askama::Template; |
|
| 8 | 9 | use axum::extract::{Form, Query, State}; |
|
| … | 65 unchanged lines |
||
| 74 | 75 | next: String, |
|
| 75 | 76 | } |
|
| 76 | 77 | ||
| 77 | - | pub async fn login(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LoginForm>) -> Result<Response, Error> { |
|
| 78 | + | pub async fn login( |
|
| 79 | + | State(app): State<Shared>, |
|
| 80 | + | viewer: Viewer, |
|
| 81 | + | headers: axum::http::HeaderMap, |
|
| 82 | + | extensions: axum::http::Extensions, |
|
| 83 | + | Form(f): Form<LoginForm>, |
|
| 84 | + | ) -> Result<Response, Error> { |
|
| 78 | 85 | let shell = Shell::new(&app, "/login", &viewer); |
|
| 79 | 86 | let next = safe_next(Some(f.next.clone())); |
|
| 80 | 87 | let username = f.username.trim().to_string(); |
|
| … | 19 unchanged lines |
||
| 100 | 107 | }) |
|
| 101 | 108 | .into_response() |
|
| 102 | 109 | }; |
|
| 110 | + | // Guessing is slowed per account and per address (oxygenhub#221), |
|
| 111 | + | // before the password is even looked at. |
|
| 112 | + | let addr = crate::api::client_addr(&app, &headers, &extensions); |
|
| 113 | + | let keys = [Key::account("login", &username), Key::address("login", &addr)]; |
|
| 114 | + | if let Some(wait) = app.throttle.lock().unwrap().wait(&keys, now_ms) { |
|
| 115 | + | return Ok(fail(&throttle::message(wait), proven.then(|| username.clone()))); |
|
| 116 | + | } |
|
| 117 | + | let failed = |app: &Shared| { |
|
| 118 | + | let wait = app.throttle.lock().unwrap().failed(&keys, now_ms); |
|
| 119 | + | let (app, username, addr) = (app.clone(), username.clone(), addr.clone()); |
|
| 120 | + | async move { |
|
| 121 | + | if let Some(wait) = wait { |
|
| 122 | + | let detail = format!("from {} for {}s", if addr.is_empty() { "an unknown address" } else { &addr }, wait / 1000); |
|
| 123 | + | accounts::audit(&app.db, None, "login.throttled", &username, &detail).await.ok(); |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | }; |
|
| 103 | 127 | if !f.pending.is_empty() && !proven { |
|
| 104 | 128 | return Ok(fail("That took too long: sign in again.", None)); |
|
| 105 | 129 | } |
|
| 106 | 130 | let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else { |
|
| 107 | 131 | accounts::audit(&app.db, None, "login.failed", &username, "").await.ok(); |
|
| 132 | + | failed(&app).await; |
|
| 108 | 133 | return Ok(fail("Wrong username or password.", None)); |
|
| 109 | 134 | }; |
|
| 110 | 135 | if account.banned { |
|
| … | 15 unchanged lines |
||
| 126 | 151 | } |
|
| 127 | 152 | None => { |
|
| 128 | 153 | accounts::audit(&app.db, Some(&account), "login.totp_failed", &username, "").await.ok(); |
|
| 154 | + | failed(&app).await; |
|
| 129 | 155 | return Ok(fail("That code is not right.", Some(username))); |
|
| 130 | 156 | } |
|
| 131 | 157 | } |
|
| 132 | 158 | } |
|
| 133 | 159 | } |
|
| 160 | + | app.throttle.lock().unwrap().cleared(&keys[0]); |
|
| 134 | 161 | accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); |
|
| 135 | 162 | Ok(signed_in(&app, &account, &next)) |
|
| 136 | 163 | } |
|
| … | 138 unchanged lines |
modified crates/hub/tests/accounts.rs +49 −0
| … | 1242 unchanged lines |
||
| 1243 | 1243 | let cookie = r.headers().get(header::SET_COOKIE).expect("signed in").to_str().unwrap(); |
|
| 1244 | 1244 | assert!(cookie.contains("; Secure") && cookie.contains("HttpOnly"), "{cookie}"); |
|
| 1245 | 1245 | } |
|
| 1246 | + | ||
| 1247 | + | #[tokio::test] |
|
| 1248 | + | async fn guessing_passwords_is_slowed_per_account_and_per_address() { |
|
| 1249 | + | hermetic(); |
|
| 1250 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1251 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1252 | + | config.server.behind_proxy = true; |
|
| 1253 | + | config.registration.mode = "open".into(); |
|
| 1254 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 1255 | + | let from = |addr: &'static str, form: String| { |
|
| 1256 | + | let app = app.clone(); |
|
| 1257 | + | async move { |
|
| 1258 | + | let req = Request::builder() |
|
| 1259 | + | .method("POST") |
|
| 1260 | + | .uri("/login") |
|
| 1261 | + | .header("x-forwarded-for", addr) |
|
| 1262 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1263 | + | let res = oxygenhub::router(app).oneshot(req.body(Body::from(form)).unwrap()).await.unwrap(); |
|
| 1264 | + | let status = res.status(); |
|
| 1265 | + | (status, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 1266 | + | } |
|
| 1267 | + | }; |
|
| 1268 | + | register(&app, "andy", "").await; |
|
| 1269 | + | let right = || "username=andy&password=correct-horse-battery".to_string(); |
|
| 1270 | + | ||
| 1271 | + | // An account: five wrong passwords cost nothing, the sixth starts a |
|
| 1272 | + | // wait, and then even the right password waits (told when to retry). |
|
| 1273 | + | for i in 0..6 { |
|
| 1274 | + | let (_, body) = from("198.51.100.1", "username=andy&password=wrong".into()).await; |
|
| 1275 | + | assert!(body.contains("Wrong username or password."), "try {i}"); |
|
| 1276 | + | } |
|
| 1277 | + | let (status, body) = from("198.51.100.2", right()).await; |
|
| 1278 | + | assert_eq!(status, StatusCode::OK, "not signed in"); |
|
| 1279 | + | assert!(body.contains("Too many tries: try again in"), "{body}"); |
|
| 1280 | + | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 1281 | + | assert!(audit.iter().any(|r| r.action == "login.throttled" && r.target == "andy" && r.detail.contains("198.51.100.1"))); |
|
| 1282 | + | app.throttle.lock().unwrap().cleared(&oxygenhub::throttle::Key::account("login", "andy")); |
|
| 1283 | + | ||
| 1284 | + | // An address: twenty misses on any names, then it waits whoever it |
|
| 1285 | + | // names; another address does not. |
|
| 1286 | + | for i in 0..21 { |
|
| 1287 | + | let (_, body) = from("203.0.113.7", format!("username=nobody{i}&password=wrong")).await; |
|
| 1288 | + | assert!(body.contains("Wrong username or password."), "try {i}"); |
|
| 1289 | + | } |
|
| 1290 | + | let (_, body) = from("203.0.113.7", right()).await; |
|
| 1291 | + | assert!(body.contains("Too many tries"), "{body}"); |
|
| 1292 | + | let (status, _) = from("198.51.100.3", right()).await; |
|
| 1293 | + | assert_eq!(status, StatusCode::SEE_OTHER, "another address signs in"); |
|
| 1294 | + | } |
modified docs/deploy.md +16 −0
| … | 49 unchanged lines |
||
| 50 | 50 | for the security headers (#222) and the site owner's decision, because |
|
| 51 | 51 | browsers remember it for months. |
|
| 52 | 52 | ||
| 53 | + | ## Guessing is slowed (oxygenhub#221) |
|
| 54 | + | ||
| 55 | + | Password sign-in, the two-factor code step, device codes and password |
|
| 56 | + | reset requests count failures per account and per client address |
|
| 57 | + | (`behind_proxy = true` takes the address from the tunnel's |
|
| 58 | + | `CF-Connecting-IP`, so set it when the hub is behind one). An account |
|
| 59 | + | gets five free failures and an address twenty (people share addresses); |
|
| 60 | + | after that each failure doubles the wait before the next try, from two |
|
| 61 | + | seconds up to a fifteen-minute lockout, and the page says when to try |
|
| 62 | + | again. A reset request counts as a try whatever it matched, so the |
|
| 63 | + | answer still gives nothing away. A quiet hour forgets the count and a |
|
| 64 | + | successful sign-in clears the account's. Each wait that starts is in the |
|
| 65 | + | audit log (`login.throttled`, `device.throttled`, |
|
| 66 | + | `password.reset_throttled`) with the address. The counts live in memory, |
|
| 67 | + | so a restart clears them. |
|
| 68 | + | ||
| 53 | 69 | ## Outbound requests (oxygenhub#219) |
|
| 54 | 70 | ||
| 55 | 71 | Webhooks and AI assistants call addresses people give, so the hub only |
|
| … | 13 unchanged lines |
modified templates/reset.html +1 −0
| … | 10 unchanged lines |
||
| 11 | 11 | <p>Send a new one from <a href="/settings">Settings</a>.</p> |
|
| 12 | 12 | {% else if mode == "request" %} |
|
| 13 | 13 | <div class="head"><h1>Reset your password</h1></div> |
|
| 14 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 14 | 15 | {% if mail %} |
|
| 15 | 16 | <form class="card" method="post" action="/login/reset"> |
|
| 16 | 17 | <label for="who">Username or verified email</label><input type="text" id="who" name="who" autocomplete="username" autofocus required> |
|
| … | 27 unchanged lines |