OXYGENHUB

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