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
@@ -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