From 1dfe2c7aadc56de012bc60812b1a9de7af6ccb52 Mon Sep 17 00:00:00 2001 From: AkaraChen <85140972+AkaraChen@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:21:34 +0800 Subject: [PATCH] fix(api): bind git scan session credentials to URL --- crates/api/src/routes/skills.rs | 130 ++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 15 deletions(-) diff --git a/crates/api/src/routes/skills.rs b/crates/api/src/routes/skills.rs index 55248603..6b3e0c1d 100644 --- a/crates/api/src/routes/skills.rs +++ b/crates/api/src/routes/skills.rs @@ -1120,14 +1120,58 @@ pub fn get_project_skill_lock( })) } +fn git_scan_session_data( + session: &GitCloneSession, + request_url: &str, + reuse_credentials: bool, +) -> Result<(Option, Option>), ApiError> { + if session.url != request_url { + if reuse_credentials && session.credential_token.is_some() { + return Err(ApiError::new( + Status::BadRequest, + concat!( + "Cannot reuse a credential-bearing git scan ", + "session for a different repository URL" + ), + "SESSION_URL_MISMATCH", + )); + } + + return Ok((None, None)); + } + + let credential_token = if reuse_credentials { + session.credential_token.clone() + } else { + None + }; + + Ok((credential_token, Some(session.branches.clone()))) +} + #[post("/skills/git/scan", data = "")] pub async fn git_scan_skills( body: Json, sessions: &rocket::State, ) -> ApiResult { let req = body.into_inner(); + let reuse_session_credential = req.credential_id.is_none(); - // Resolve credential token — either from session or from request + let (session_credential_token, cached_branches) = if let Some(ref sid) = + req.session_id + { + let map = sessions.sessions.lock().unwrap(); + if let Some(session) = map.get(sid) { + git_scan_session_data(session, &req.url, reuse_session_credential)? + } else { + (None, None) + } + } else { + (None, None) + }; + + // Resolve credential token — either from request or, when the + // requested URL matches the original scan URL, from the session. let credential_token: Option = if let Some(ref cred_id) = req.credential_id { let creds = crate::routes::credentials::load_credentials() @@ -1147,21 +1191,8 @@ pub async fn git_scan_skills( ) })?; Some(cred.token.clone()) - } else if let Some(ref sid) = req.session_id { - // Reuse credential from existing session - let map = sessions.sessions.lock().unwrap(); - map.get(sid).and_then(|s| s.credential_token.clone()) - } else { - None - }; - - // Retrieve cached branches from existing session if re-scanning - let cached_branches: Option> = - if let Some(ref sid) = req.session_id { - let map = sessions.sessions.lock().unwrap(); - map.get(sid).map(|s| s.branches.clone()) } else { - None + session_credential_token }; let url = req.url.clone(); @@ -1601,6 +1632,75 @@ mod tests { assert!(!project_root.join(".agents/skills/repo-helper").exists()); } + #[test] + fn git_scan_session_data_reuses_matching_session_credentials() { + let temp_dir = tempfile::tempdir().unwrap(); + let session = GitCloneSession { + temp_dir, + created_at: std::time::Instant::now(), + url: "https://github.com/example/repo.git".to_string(), + credential_token: Some("secret-token".to_string()), + branches: vec!["main".to_string()], + current_branch: "main".to_string(), + }; + + let (token, branches) = git_scan_session_data( + &session, + "https://github.com/example/repo.git", + true, + ) + .unwrap(); + + assert_eq!(token, Some("secret-token".to_string())); + assert_eq!(branches, Some(vec!["main".to_string()])); + } + + #[test] + fn git_scan_session_data_rejects_credential_reuse_url_mismatch() { + let temp_dir = tempfile::tempdir().unwrap(); + let session = GitCloneSession { + temp_dir, + created_at: std::time::Instant::now(), + url: "https://github.com/example/repo.git".to_string(), + credential_token: Some("secret-token".to_string()), + branches: vec!["main".to_string()], + current_branch: "main".to_string(), + }; + + let error = git_scan_session_data( + &session, + "https://attacker.example/collect.git", + true, + ) + .unwrap_err(); + + assert_eq!(error.status, Status::BadRequest); + assert_eq!(error.body.code, "SESSION_URL_MISMATCH"); + } + + #[test] + fn git_scan_session_data_ignores_mismatched_session_without_reuse() { + let temp_dir = tempfile::tempdir().unwrap(); + let session = GitCloneSession { + temp_dir, + created_at: std::time::Instant::now(), + url: "https://github.com/example/repo.git".to_string(), + credential_token: Some("secret-token".to_string()), + branches: vec!["main".to_string()], + current_branch: "main".to_string(), + }; + + let (token, branches) = git_scan_session_data( + &session, + "https://attacker.example/collect.git", + false, + ) + .unwrap(); + + assert_eq!(token, None); + assert_eq!(branches, None); + } + #[test] fn list_branches_for_scan_returns_cached_without_fetching() { let runtime = tokio::runtime::Runtime::new().unwrap();