diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index c53a8dc..6757829 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -195,18 +195,26 @@ where } /// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。 +/// +/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 +/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。 fn nonce() -> String { use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hasher}; - let mut hasher = RandomState::new().build_hasher(); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|since| since.as_nanos()) .unwrap_or(0); - hasher.write_u128(nanos); - hasher.write_u32(std::process::id()); - format!("{:016x}", hasher.finish()) + let mut halves = [String::new(), String::new()]; + for half in &mut halves { + // 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。 + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u128(nanos); + hasher.write_u32(std::process::id()); + *half = format!("{:016x}", hasher.finish()); + } + format!("{}{}", halves[0], halves[1]) } /// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。 @@ -264,4 +272,13 @@ mod tests { fn grant_type_is_the_standard_one() { assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code"); } + + #[test] + fn generated_state_has_128_bits_of_entropy() { + let first = super::nonce(); + let second = super::nonce(); + assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}"); + assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}"); + assert_ne!(first, second, "每次登录的 state 必须不同"); + } } diff --git a/crates/core/src/auth/microsoft/callback.rs b/crates/core/src/auth/microsoft/callback.rs index 4d9262f..2e8a27c 100644 --- a/crates/core/src/auth/microsoft/callback.rs +++ b/crates/core/src/auth/microsoft/callback.rs @@ -12,7 +12,9 @@ //! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。 //! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon //! 把整次登录判成失败是最容易犯的错。 -//! 4. **`state` 不匹配的迟到回调回 400 后继续等**:旧标签页的回调不该打断新的登录; +//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的 +//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样 +//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。 //! 真正的超时由调用方的时间预算兜底。 use std::time::Duration; @@ -131,7 +133,12 @@ impl CallbackServer { Err(_) => return Err(wait_timeout(timeout)), }; // 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。 - let budget = REQUEST_READ_TIMEOUT.min(remaining); + // 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。 + let budget = REQUEST_READ_TIMEOUT + .min(deadline.saturating_duration_since(tokio::time::Instant::now())); + if budget.is_zero() { + return Err(wait_timeout(timeout)); + } match tokio::time::timeout(budget, self.handle(&mut stream)).await { Ok(result) => { if let Some(code) = result? { @@ -157,14 +164,17 @@ impl CallbackServer { return Ok(None); } let params = Query::parse(query); - if let Some(error) = params.get("error") { - respond(stream, 200, DENIED_PAGE).await; - return Err(oauth_error(error, params.get("error_description"))); - } + // state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 / + // RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能 + // 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。 if params.get("state") != Some(self.state.as_str()) { respond(stream, 400, BAD_REQUEST_PAGE).await; return Ok(None); } + if let Some(error) = params.get("error") { + respond(stream, 200, DENIED_PAGE).await; + return Err(oauth_error(error, params.get("error_description"))); + } let Some(code) = params.get("code").filter(|code| !code.is_empty()) else { respond(stream, 400, BAD_REQUEST_PAGE).await; return Ok(None); diff --git a/crates/core/tests/auth/microsoft_authcode.rs b/crates/core/tests/auth/microsoft_authcode.rs index 70ff618..303101c 100644 --- a/crates/core/tests/auth/microsoft_authcode.rs +++ b/crates/core/tests/auth/microsoft_authcode.rs @@ -140,6 +140,62 @@ async fn callback_server_ignores_unrelated_requests_and_bad_state() { browser.await.expect("模拟浏览器任务"); } +#[tokio::test] +async fn foreign_state_cannot_cancel_the_login() { + // P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。 + // 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。 + let server = CallbackServer::bind(None, 1, "state-ok") + .await + .expect("绑定"); + let port = server.port(); + let browser = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let base = format!("http://localhost:{port}/auth"); + // 1. 连 state 都不给:与本次登录无关 → 400,继续等。 + let bare = reqwest::get(format!("{base}?error=access_denied")) + .await + .expect("伪造取消"); + assert_eq!(bare.status(), 400); + // 2. state 明显不匹配:同样忽略,绝不当成用户取消。 + let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state")) + .await + .expect("伪造取消带错 state"); + assert_eq!(foreign.status(), 400); + // 3. 真正带对 state 的回调仍然能完成登录。 + let good = reqwest::get(format!("{base}?code=good-code&state=state-ok")) + .await + .expect("真回调"); + assert_eq!(good.status(), 200); + }); + + let code = server + .wait_for_code(Duration::from_secs(5)) + .await + .expect("伪造的 access_denied 不能中止登录"); + assert_eq!(code, "good-code"); + browser.await.expect("模拟浏览器任务"); +} + +#[tokio::test] +async fn access_denied_with_our_state_is_still_canceled() { + // 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。 + let server = CallbackServer::bind(None, 1, "state-ok") + .await + .expect("绑定"); + let port = server.port(); + let browser = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok"); + let _ = reqwest::get(url).await; + }); + let error = server + .wait_for_code(Duration::from_secs(5)) + .await + .expect_err("本机 state 的取消要生效"); + assert_eq!(error, AuthError::Canceled); + browser.await.expect("模拟浏览器任务"); +} + #[tokio::test] async fn callback_timeout_is_reported() { let auth = MicrosoftAuth::new(CLIENT_ID)