Skip to main content

fluidattacks_core_git/
remote.rs

1use anyhow::{Context, Result};
2use base64::Engine;
3use std::time::Duration;
4use tokio::process::Command;
5
6use super::aws;
7use super::types::{CredentialKind, Credentials, LsRemoteResult};
8use crate::clone::https::HttpsAccess;
9use crate::clone::reason::hide;
10use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};
11
12const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
13
14/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
15const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
16
17struct AwsRole {
18    arn: String,
19    external_id: String,
20    via: Option<String>,
21}
22
23fn redirect_config(follow_redirects: bool) -> [String; 2] {
24    [
25        "-c".to_owned(),
26        format!("http.followRedirects={follow_redirects}"),
27    ]
28}
29
30async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<LsRemoteResult> {
31    let Ok(output) = tokio::time::timeout(
32        Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
33        command.kill_on_drop(true).output(),
34    )
35    .await
36    else {
37        return Ok(LsRemoteResult {
38            commit: None,
39            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
40        });
41    };
42
43    Ok(parse_ls_remote_output(
44        &output.context("running git ls-remote")?,
45        secrets,
46    ))
47}
48
49pub async fn ls_remote(
50    url: &str,
51    branch: &str,
52    credentials: Option<&Credentials>,
53    follow_redirects: bool,
54) -> Result<LsRemoteResult> {
55    match credentials.map(|c| &c.kind) {
56        Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
57        Some(CredentialKind::AwsRole {
58            arn,
59            external_id,
60            via,
61        }) => {
62            let role = AwsRole {
63                arn: arn.clone(),
64                external_id: external_id.clone(),
65                via: via.clone(),
66            };
67            ls_remote_codecommit(url, branch, &role, follow_redirects).await
68        }
69        kind => {
70            let access = HttpsAccess::new(url, kind)?;
71            ls_remote_https(&access, branch, follow_redirects).await
72        }
73    }
74}
75
76async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
77    let key_data = base64::engine::general_purpose::STANDARD
78        .decode(key_b64)
79        .context("decoding SSH key")?;
80
81    let key_file = tempfile::Builder::new()
82        .prefix("melts-ssh-key-")
83        .tempfile()
84        .context("creating SSH key file")?;
85
86    std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
87
88    #[cfg(unix)]
89    {
90        use std::os::unix::fs::PermissionsExt;
91        std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
92            .context("setting SSH key permissions")?;
93    }
94
95    let git_ssh_command = setup_ssh_env(key_file.path());
96    let normalized_url = normalize_ssh_url(url);
97
98    let args = [
99        "ls-remote".to_owned(),
100        "--".to_owned(),
101        normalized_url,
102        branch.to_owned(),
103    ];
104
105    let mut command = Command::new("git");
106    command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
107    run_ls_remote(&mut command, &[]).await
108}
109
110async fn ls_remote_https(
111    access: &HttpsAccess,
112    branch: &str,
113    follow_redirects: bool,
114) -> Result<LsRemoteResult> {
115    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
116    args.extend(redirect_config(follow_redirects));
117    args.extend([
118        "ls-remote".to_owned(),
119        "--".to_owned(),
120        access.url.clone(),
121        branch.to_owned(),
122    ]);
123
124    let mut command = access.git();
125    command.args(&args);
126    run_ls_remote(&mut command, access.secret().as_slice()).await
127}
128
129async fn ls_remote_codecommit(
130    url: &str,
131    branch: &str,
132    role: &AwsRole,
133    follow_redirects: bool,
134) -> Result<LsRemoteResult> {
135    let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
136
137    let args = codecommit_ls_remote_args(url, branch, follow_redirects);
138
139    let mut command = Command::new("git");
140    command
141        .args(&args)
142        .env("AWS_ACCESS_KEY_ID", &creds.access_key)
143        .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
144        .env("AWS_SESSION_TOKEN", &creds.session_token)
145        .env("AWS_DEFAULT_REGION", &creds.region);
146    run_ls_remote(&mut command, &[]).await
147}
148
149/// The `git ls-remote` arguments for a `codecommit::` url. git checks the
150/// certificate, since the helper always reaches AWS's own endpoint.
151fn codecommit_ls_remote_args(url: &str, branch: &str, follow_redirects: bool) -> Vec<String> {
152    let mut args = redirect_config(follow_redirects).to_vec();
153    args.extend([
154        "ls-remote".to_owned(),
155        "--".to_owned(),
156        url.to_owned(),
157        branch.to_owned(),
158    ]);
159    args
160}
161
162fn parse_ls_remote_output(output: &std::process::Output, secrets: &[&str]) -> LsRemoteResult {
163    if !output.status.success() {
164        let stderr = String::from_utf8_lossy(&output.stderr);
165        return LsRemoteResult {
166            commit: None,
167            error: Some(hide(&stderr, secrets)),
168        };
169    }
170
171    let stdout = String::from_utf8_lossy(&output.stdout);
172    let commit = stdout
173        .lines()
174        .next()
175        .and_then(|line| line.split('\t').next())
176        .unwrap_or_default()
177        .trim()
178        .to_owned();
179
180    LsRemoteResult {
181        commit: Some(commit),
182        error: None,
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use std::io::{BufRead, BufReader, Write};
189    use std::net::{TcpListener, TcpStream};
190    use std::sync::{Arc, Mutex};
191
192    use base64::Engine;
193
194    use super::{
195        codecommit_ls_remote_args, ls_remote, parse_ls_remote_output, redirect_config,
196        run_ls_remote,
197    };
198    use crate::clone::https::HttpsAccess;
199    use crate::types::{CredentialKind, Credentials};
200
201    // The path asked for and the Authorization it carried.
202    fn read_request(stream: &TcpStream) -> (String, Option<String>) {
203        let mut path = String::new();
204        let mut authorization = None;
205        let mut reader = BufReader::new(stream);
206        let mut line = String::new();
207        while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
208            let header = line.trim_end();
209            if header.is_empty() {
210                break;
211            }
212            if path.is_empty() {
213                header
214                    .split(' ')
215                    .nth(1)
216                    .unwrap_or_default()
217                    .clone_into(&mut path);
218            }
219            if let Some(value) = header.strip_prefix("Authorization: ") {
220                authorization = Some(value.to_owned());
221            }
222            line.clear();
223        }
224        (path, authorization)
225    }
226
227    fn answer(redirect_to: Option<&str>, path: &str) -> String {
228        redirect_to.map_or_else(
229            || {
230                "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
231                 Content-Length: 0\r\nConnection: close\r\n\r\n"
232                    .to_owned()
233            },
234            |host| {
235                format!(
236                    "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
237                     Content-Length: 0\r\nConnection: close\r\n\r\n"
238                )
239            },
240        )
241    }
242
243    // A host that sends every request to `redirect_to` when given one, and
244    // otherwise turns it down; it keeps the Authorization each request carried.
245    fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
246        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
247        let port = listener.local_addr().expect("addr").port();
248        let seen = Arc::new(Mutex::new(Vec::new()));
249        let record = Arc::clone(&seen);
250        std::thread::spawn(move || {
251            for stream in listener.incoming().flatten() {
252                let (path, authorization) = read_request(&stream);
253                record.lock().expect("lock").push(authorization);
254                let answer = answer(redirect_to.as_deref(), &path);
255                let _ = (&stream).write_all(answer.as_bytes());
256            }
257        });
258        (format!("http://127.0.0.1:{port}"), seen)
259    }
260
261    fn token(token: &str) -> Credentials {
262        Credentials {
263            kind: CredentialKind::Token {
264                token: token.to_owned(),
265                is_pat: false,
266                oauth_type: String::new(),
267            },
268        }
269    }
270
271    fn pat(token: &str) -> Credentials {
272        Credentials {
273            kind: CredentialKind::Token {
274                token: token.to_owned(),
275                is_pat: true,
276                oauth_type: String::new(),
277            },
278        }
279    }
280
281    fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
282        use std::os::unix::process::ExitStatusExt;
283        std::process::Output {
284            status: std::process::ExitStatus::from_raw(code << 8),
285            stdout: stdout.as_bytes().to_vec(),
286            stderr: stderr.as_bytes().to_vec(),
287        }
288    }
289
290    #[test]
291    fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
292        assert_eq!(
293            redirect_config(false),
294            ["-c".to_owned(), "http.followRedirects=false".to_owned()]
295        );
296        assert_eq!(
297            redirect_config(true),
298            ["-c".to_owned(), "http.followRedirects=true".to_owned()]
299        );
300    }
301
302    #[test]
303    fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
304        let result = parse_ls_remote_output(&output(0, "", ""), &[]);
305        assert_eq!(result.commit, Some(String::new()));
306        assert!(result.error.is_none());
307    }
308
309    #[test]
310    fn the_first_ref_is_the_commit() {
311        let result = parse_ls_remote_output(
312            &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
313            &[],
314        );
315        assert_eq!(result.commit.as_deref(), Some("abc123"));
316    }
317
318    #[test]
319    fn a_failure_reports_the_stderr_and_no_commit() {
320        let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
321        assert!(result.commit.is_none());
322        assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
323    }
324
325    #[tokio::test]
326    async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
327        for (credentials, login) in [
328            (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
329            (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
330        ] {
331            let (host, seen) = serve(None);
332            let result = ls_remote(
333                &format!("{host}/org/repo.git"),
334                "main",
335                Some(&credentials),
336                false,
337            )
338            .await
339            .unwrap();
340
341            let sent = base64::engine::general_purpose::STANDARD.encode(login);
342            assert!(seen
343                .lock()
344                .unwrap()
345                .contains(&Some(format!("Basic {sent}"))));
346            let error = result.error.unwrap();
347            assert!(error.contains("Authentication failed"), "{error}");
348            assert!(!error.contains("Zm9v"), "{error}");
349        }
350    }
351
352    #[tokio::test]
353    async fn a_redirect_to_another_host_carries_no_credential() {
354        for credentials in [token("tok123"), pat("tok123")] {
355            let (other, other_seen) = serve(None);
356            let other = other.replace("127.0.0.1", "localhost");
357            let (host, _) = serve(Some(other));
358            let result = ls_remote(
359                &format!("{host}/org/repo.git"),
360                "main",
361                Some(&credentials),
362                true,
363            )
364            .await
365            .unwrap();
366
367            assert!(result.error.is_some());
368            let other_seen = other_seen.lock().unwrap().clone();
369            assert!(!other_seen.is_empty());
370            assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
371        }
372    }
373
374    #[tokio::test]
375    async fn a_helper_the_caller_configured_never_sees_the_credential() {
376        let (host, _) = serve(None);
377        let calls = tempfile::NamedTempFile::new().unwrap();
378        let access =
379            HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
380        let mut command = access.git();
381        command
382            .env(
383                "GIT_CONFIG_PARAMETERS",
384                format!(
385                    "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
386                    calls.path().display()
387                ),
388            )
389            .args(["ls-remote", "--", &access.url, "main"]);
390        run_ls_remote(&mut command, access.secret().as_slice())
391            .await
392            .unwrap();
393
394        assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
395    }
396
397    #[test]
398    fn a_failure_hides_the_credential_a_server_echoes() {
399        let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
400        let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
401        assert_eq!(
402            result.error.as_deref(),
403            Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
404        );
405    }
406
407    #[test]
408    fn a_codecommit_url_lets_git_check_the_certificate() {
409        let args = codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", false);
410        assert!(!args.contains(&"http.sslVerify=false".to_owned()));
411    }
412}