fix(core): 回调先校验 state 再处理 error,堵掉伪造取消失效登录
reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、 之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied` (state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库 —— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207 (错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。 - state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等; 只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。 - 顺带采纳 reviewer 的两条非计数观察: * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining, 总时长可超发 ≤5s);预算归零直接超时。 * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐 RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。 - 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs): `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state) 再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待, 随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。 既有的「陈旧 state 回 400 后继续等」用例保持通过。 门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。 AskaEth <i@yeij.top>
This commit is contained in:
1 parent
8194e38bcf
commit
07fd6113a7
3 files changed
+93
-10
No files matched your search
@@ -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 必须不同");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user