Skip to main content

fluidattacks_core_git/clone/
https.rs

1use anyhow::{Context, Result};
2use base64::Engine;
3use http::uri::{PathAndQuery, Uri};
4use std::fmt;
5use std::net::IpAddr;
6use std::path::Path;
7use tokio::process::Command;
8use url::Url;
9
10use super::{build_clone_args, reason, redirect_config, stall_config, CloneOpts};
11use crate::errors::{GitCallError, HostError, HttpsAccessError};
12use crate::local_address::{outside_addresses, read_host, AllowedLocal};
13use crate::types::CredentialKind;
14
15const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
16const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
17
18// Answers only `get`, so git has nowhere to store or erase the credential.
19fn env_helper() -> String {
20    format!(
21        "!f() {{ if [ \"$1\" = get ]; then \
22         printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
23    )
24}
25
26// Read as git and curl read it (RFC 3986), host and path left as written: git
27// normalizes the url and the helper key alike when it matches one to the other.
28struct BareUrl {
29    url: String,
30    origin: String,
31}
32
33/// How git reaches a repo over https, with the credential kept off its command
34/// line and out of the url, so that git has none of it to print back.
35///
36/// `git` hands the credential to the repo's own host and to no other, and
37/// never lets git prompt. `secret` is what is still worth taking out of an
38/// error, because a server may echo what it was sent.
39pub struct HttpsAccess {
40    url: String,
41    secret: Option<String>,
42    config: Vec<(String, String)>,
43    env: Vec<(String, String)>,
44}
45
46// By hand, so that printing it, in a log or a failed unwrap, leaves the login out.
47impl fmt::Debug for HttpsAccess {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("HttpsAccess")
50            .field("url", &self.url)
51            .finish_non_exhaustive()
52    }
53}
54
55impl HttpsAccess {
56    pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self, HttpsAccessError> {
57        match credential {
58            None => Ok(Self::public(url)),
59            Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
60            Some(CredentialKind::Token {
61                token,
62                is_pat,
63                oauth_type,
64            }) => {
65                let (user, password) = token_login(token, oauth_type, *is_pat);
66                Ok(Self {
67                    secret: Some(token.clone()),
68                    ..Self::basic(url, user, password)?
69                })
70            }
71            Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
72                Err(HttpsAccessError::NotHttpsCredential)
73            }
74        }
75    }
76
77    fn public(url: &str) -> Self {
78        Self {
79            url: url.to_owned(),
80            secret: None,
81            config: Vec::new(),
82            env: Vec::new(),
83        }
84    }
85
86    fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self, HttpsAccessError> {
87        if [user, password]
88            .iter()
89            .any(|value| value.contains(['\n', '\r', '\0']))
90        {
91            return Err(HttpsAccessError::CredentialLineBreak);
92        }
93        let url = bare_url(raw_url)?;
94        Ok(Self {
95            secret: Some(password.to_owned()),
96            config: vec![(format!("credential.{}.helper", url.origin), env_helper())],
97            env: vec![
98                (USER_VAR.to_owned(), user.to_owned()),
99                (PASSWORD_VAR.to_owned(), password.to_owned()),
100            ],
101            url: url.url,
102        })
103    }
104
105    #[must_use]
106    pub fn url(&self) -> &str {
107        &self.url
108    }
109
110    /// A git command for this repo, still waiting for its subcommand.
111    #[must_use]
112    pub fn git(&self) -> Command {
113        let mut command = Command::new("git");
114        // The empty helper drops whatever helpers the machine has configured,
115        // so the credential is neither looked up in nor saved to them. Going
116        // on the command line, it comes after any config git inherits.
117        command.args(["-c", "credential.helper="]);
118        for (key, value) in &self.config {
119            command.arg("-c").arg(format!("{key}={value}"));
120        }
121        command
122            .env("GIT_TERMINAL_PROMPT", "0")
123            // Unset, git keeps credentials out of its own traces.
124            .env_remove("GIT_TRACE_REDACT")
125            // Its data dump prints what a server sends back, the Basic header included.
126            .env_remove("GIT_TRACE_CURL")
127            .envs(self.env.iter().cloned());
128        command
129    }
130
131    #[must_use]
132    pub fn secret(&self) -> Option<&str> {
133        self.secret.as_deref()
134    }
135
136    pub(crate) fn pinned(mut self, pin: String) -> Self {
137        self.config.push(("http.curloptResolve".to_owned(), pin));
138        self
139    }
140}
141
142/// The `http.curloptResolve` entry that sends git only to the outside addresses of
143/// `url`'s host, read from one resolution.
144pub(crate) async fn public_pin(url: &str) -> Result<String, HostError> {
145    let uri: Uri = url.parse().map_err(|_| HostError::Unparsable)?;
146    let authority = uri.authority().ok_or(HostError::Unparsable)?;
147    let Some((host, "")) = read_host(authority.host()) else {
148        return Err(HostError::Unparsable);
149    };
150    let default_port = if uri.scheme_str() == Some("http") {
151        80
152    } else {
153        443
154    };
155    let port = authority.port_u16().unwrap_or(default_port);
156    let ips = outside_addresses(&host, AllowedLocal::default()).await?;
157    Ok(pin_entry(authority.host(), port, &ips))
158}
159
160// A name resolves to each address once per socket type, so repeats are dropped.
161fn pin_entry(host: &str, port: u16, ips: &[IpAddr]) -> String {
162    let mut addresses: Vec<String> = Vec::new();
163    for ip in ips {
164        let address = match ip {
165            IpAddr::V4(ip) => ip.to_string(),
166            IpAddr::V6(ip) => format!("[{ip}]"),
167        };
168        if !addresses.contains(&address) {
169            addresses.push(address);
170        }
171    }
172    format!("{host}:{port}:{}", addresses.join(","))
173}
174
175fn bare_url(raw_url: &str) -> Result<BareUrl, HttpsAccessError> {
176    let uri: Uri = raw_url.parse().map_err(|_| HttpsAccessError::Unparsable)?;
177    let scheme = uri
178        .scheme_str()
179        .filter(|scheme| matches!(*scheme, "http" | "https"))
180        .ok_or(HttpsAccessError::NotHttp)?;
181    let authority = uri.authority().ok_or(HttpsAccessError::Unparsable)?;
182    let port = authority
183        .port()
184        .map_or_else(String::new, |port| format!(":{port}"));
185    let host = format!("{}{port}", authority.host());
186    // An empty host, or a port the parser could not read and left out.
187    if authority.host().is_empty() || !authority.as_str().ends_with(&host) {
188        return Err(HttpsAccessError::Unparsable);
189    }
190    let origin = format!("{scheme}://{host}");
191    let path = uri.path_and_query().map_or("", PathAndQuery::as_str);
192    Ok(BareUrl {
193        url: format!("{origin}{path}"),
194        origin,
195    })
196}
197
198// A token with no provider is the user itself, with no password. A PAT takes
199// any user but an empty one, since git sends no credential without a user.
200const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
201    if is_pat {
202        ("pat", token)
203    } else if provider.eq_ignore_ascii_case("BITBUCKET") {
204        ("x-token-auth", token)
205    } else if provider.is_empty() {
206        (token, "")
207    } else {
208        ("oauth2", token)
209    }
210}
211
212pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
213    let kind = &opts
214        .credentials
215        .as_ref()
216        .context("missing credentials")?
217        .kind;
218    if !matches!(kind, CredentialKind::Https { .. }) {
219        anyhow::bail!("expected HTTPS credentials");
220    }
221    Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
222}
223
224pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
225    let kind = &opts
226        .credentials
227        .as_ref()
228        .context("missing credentials")?
229        .kind;
230    if !matches!(kind, CredentialKind::Token { .. }) {
231        anyhow::bail!("expected Token credentials");
232    }
233    Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
234}
235
236pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
237    Ok(run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await?)
238}
239
240#[must_use]
241pub fn build_https_config_args(
242    follow_redirects: bool,
243    is_pat: bool,
244    token: Option<&str>,
245    disable_credential_prompt: bool,
246) -> Vec<String> {
247    let mut args = vec![
248        "-c".to_owned(),
249        "http.sslVerify=false".to_owned(),
250        "-c".to_owned(),
251        format!("http.followRedirects={follow_redirects}"),
252    ];
253    if is_pat {
254        if let Some(t) = token {
255            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
256            args.extend([
257                "-c".to_owned(),
258                format!("http.extraHeader=Authorization: Basic {encoded}"),
259            ]);
260        }
261    }
262    if disable_credential_prompt {
263        args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
264    }
265    args
266}
267
268#[allow(clippy::too_many_arguments)]
269pub fn format_https_url(
270    repo_url: &str,
271    user: Option<&str>,
272    password: Option<&str>,
273    token: Option<&str>,
274    provider: Option<&str>,
275    is_pat: bool,
276) -> Result<String> {
277    let mut parsed = Url::parse(repo_url).context("parsing URL")?;
278    // Re-quote the path
279    let path = parsed.path().to_owned();
280    parsed.set_path(&path);
281
282    if is_pat {
283        return Ok(parsed.to_string());
284    }
285
286    if let Some(t) = token {
287        let user_part = match provider {
288            Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
289            Some("") | None => "",
290            Some(_) => "oauth2",
291        };
292        if user_part.is_empty() {
293            // No provider: use token as username, no password
294            parsed
295                .set_username(t)
296                .map_err(|()| anyhow::anyhow!("set username"))?;
297            parsed
298                .set_password(None)
299                .map_err(|()| anyhow::anyhow!("set password"))?;
300        } else {
301            // Use username/password separately to avoid percent-encoding the colon
302            parsed
303                .set_username(user_part)
304                .map_err(|()| anyhow::anyhow!("set username"))?;
305            parsed
306                .set_password(Some(t))
307                .map_err(|()| anyhow::anyhow!("set password"))?;
308        }
309        return Ok(parsed.to_string());
310    }
311
312    if let (Some(u), Some(p)) = (user, password) {
313        parsed
314            .set_username(u)
315            .map_err(|()| anyhow::anyhow!("set username"))?;
316        parsed
317            .set_password(Some(p))
318            .map_err(|()| anyhow::anyhow!("set password"))?;
319        return Ok(parsed.to_string());
320    }
321
322    Ok(parsed.to_string())
323}
324
325pub(super) async fn run_git_clone(
326    opts: &CloneOpts,
327    dest: &Path,
328    access: &HttpsAccess,
329) -> Result<(), GitCallError> {
330    let mut command = access.git();
331    command.args(clone_args(opts, dest, access));
332    reason::run(&mut command, access.secret().as_slice()).await
333}
334
335fn clone_args(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Vec<std::ffi::OsString> {
336    let mut extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
337    extra_config.extend(redirect_config(opts.follow_redirects));
338    extra_config.extend(stall_config());
339    build_clone_args(opts, access.url(), dest, &extra_config)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::{clone_args, format_https_url, pin_entry, public_pin, token_login, HttpsAccess};
345    use crate::clone::tests::opts;
346    use crate::clone::{CloneOpts, CloneShape};
347    use crate::errors::{HostError, HttpsAccessError, LocalAddress};
348    use crate::types::CredentialKind;
349    use std::ffi::OsString;
350
351    fn args(access: &HttpsAccess) -> Vec<String> {
352        access
353            .git()
354            .as_std()
355            .get_args()
356            .map(|arg| arg.to_string_lossy().into_owned())
357            .collect()
358    }
359
360    fn token(token: &str, provider: &str) -> CredentialKind {
361        CredentialKind::Token {
362            token: token.to_owned(),
363            is_pat: false,
364            oauth_type: provider.to_owned(),
365        }
366    }
367
368    #[test]
369    fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
370        assert_eq!(token_login("tok", "", false), ("tok", ""));
371    }
372
373    #[test]
374    fn the_provider_names_the_user_even_off_its_own_domain() {
375        assert_eq!(
376            token_login("tok", "BITBUCKET", false),
377            ("x-token-auth", "tok")
378        );
379        assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
380    }
381
382    #[test]
383    fn the_provider_is_matched_ignoring_the_case() {
384        let url = "https://host/a/b.git";
385
386        assert_eq!(
387            format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
388            format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
389        );
390        assert_eq!(
391            token_login("tok", "Bitbucket", false),
392            token_login("tok", "BITBUCKET", false)
393        );
394    }
395
396    fn basic(user: &str, password: &str) -> CredentialKind {
397        CredentialKind::Https {
398            user: user.to_owned(),
399            password: password.to_owned(),
400        }
401    }
402
403    #[test]
404    fn the_url_drops_whatever_credential_it_carried() {
405        let access = HttpsAccess::new(
406            "https://olduser:oldpass@host/a/b.git",
407            Some(&token("tok", "")),
408        )
409        .unwrap();
410        assert_eq!(access.url(), "https://host/a/b.git");
411    }
412
413    #[test]
414    fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
415        assert_eq!(token_login("tok", "", true), ("pat", "tok"));
416        assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
417    }
418
419    #[test]
420    fn the_host_is_left_as_written_for_git_to_read() {
421        for (url, expected) in [
422            ("https://Host:443/a.git", "https://Host:443/a.git"),
423            ("https://[::1]:8443/a.git", "https://[::1]:8443/a.git"),
424            ("https://u:p@w@host/a.git", "https://host/a.git"),
425        ] {
426            let access = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap();
427            let origin = expected.trim_end_matches("/a.git");
428            assert_eq!(access.url(), expected, "{url}");
429            let helper = format!("credential.{origin}.helper=");
430            let host = args(&access).get(3).cloned().unwrap_or_default();
431            assert!(host.starts_with(&helper), "{url}");
432        }
433    }
434
435    #[test]
436    fn each_failure_says_what_the_python_said_and_never_the_url() {
437        for (url, expected) in [
438            ("ftp://u:hunter2@host/a.git", HttpsAccessError::NotHttp),
439            ("host/a.git", HttpsAccessError::Unparsable),
440            ("https:u:hunter2@host/a.git", HttpsAccessError::Unparsable),
441            ("https:///host/a.git", HttpsAccessError::Unparsable),
442            ("https://u:hunter2@/a.git", HttpsAccessError::Unparsable),
443            ("https://u:hunter2@:443/a.git", HttpsAccessError::Unparsable),
444            (
445                "https://u:hunter2@host:99999/a.git",
446                HttpsAccessError::Unparsable,
447            ),
448            (
449                "https://u:hunter2@faß.de/a.git",
450                HttpsAccessError::Unparsable,
451            ),
452            (
453                "https://tok\\hunter2@host/a.git",
454                HttpsAccessError::Unparsable,
455            ),
456        ] {
457            let error = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap_err();
458            assert_eq!(error, expected, "{url}");
459            assert!(!error.to_string().contains("hunter2"), "{url}");
460        }
461        assert_eq!(
462            HttpsAccessError::NotHttp.to_string(),
463            "expected an http or https url"
464        );
465        assert_eq!(
466            HttpsAccessError::Unparsable.to_string(),
467            "Invalid url: its host or port could not be parsed"
468        );
469    }
470
471    #[test]
472    fn no_credential_leaves_the_url_unchecked() {
473        let access = HttpsAccess::new("ftp://host:99999/a.git", None).unwrap();
474        assert_eq!(access.url(), "ftp://host:99999/a.git");
475        assert_eq!(args(&access), ["-c", "credential.helper="]);
476    }
477
478    #[test]
479    fn the_debug_shows_neither_the_login_nor_the_environment() {
480        let access =
481            HttpsAccess::new("https://host/a.git", Some(&basic("someone", "s3cr3t"))).unwrap();
482        let shown = format!("{access:?}");
483        assert!(shown.contains("https://host/a.git"), "{shown}");
484        assert!(!shown.contains("s3cr3t"), "{shown}");
485        assert!(!shown.contains("someone"), "{shown}");
486    }
487
488    #[test]
489    fn the_machine_helpers_are_dropped_before_the_host_helper() {
490        let access = HttpsAccess::new("https://host/a.git", Some(&token("tok", ""))).unwrap();
491        let args = args(&access);
492        let [flag, machine, host_flag, host] = args.as_slice() else {
493            panic!("{args:?}");
494        };
495        assert_eq!(
496            [flag, machine, host_flag],
497            ["-c", "credential.helper=", "-c"]
498        );
499        assert!(
500            host.starts_with("credential.https://host.helper=!f()"),
501            "{host}"
502        );
503    }
504
505    #[test]
506    fn the_login_goes_through_the_environment() {
507        let access = HttpsAccess::new("https://host/a.git", Some(&basic("u", "p"))).unwrap();
508        let envs: Vec<_> = access
509            .git()
510            .as_std()
511            .get_envs()
512            .map(|(name, value)| (name.to_owned(), value.map(ToOwned::to_owned)))
513            .collect();
514        let set = |value: &str| Some(OsString::from(value));
515        assert_eq!(
516            envs,
517            [
518                ("FLUIDATTACKS_GIT_PASSWORD".into(), set("p")),
519                ("FLUIDATTACKS_GIT_USER".into(), set("u")),
520                ("GIT_TERMINAL_PROMPT".into(), set("0")),
521                ("GIT_TRACE_CURL".into(), None),
522                ("GIT_TRACE_REDACT".into(), None),
523            ]
524        );
525    }
526
527    #[test]
528    fn a_credential_with_a_line_break_or_a_nul_is_refused() {
529        for credential in [
530            token("tok\nhost=x", ""),
531            token("tok\0x", "GITLAB"),
532            basic("u\rx", "p"),
533            basic("u", "p\nx"),
534        ] {
535            let error = HttpsAccess::new("https://host/a/b.git", Some(&credential)).unwrap_err();
536            assert_eq!(error, HttpsAccessError::CredentialLineBreak);
537            assert_eq!(
538                error.to_string(),
539                "Invalid credentials: a line break or a nul byte"
540            );
541        }
542    }
543
544    #[test]
545    fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
546        let url = "https://gitlab.com/a/b.git";
547        let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
548        assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
549        assert_eq!(
550            format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
551            no_provider
552        );
553    }
554
555    #[test]
556    fn a_redirect_is_followed_only_when_the_root_asks_for_one() {
557        for follow_redirects in [false, true] {
558            let opts = CloneOpts {
559                follow_redirects,
560                ..opts(CloneShape::SingleBranch)
561            };
562            let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
563
564            let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
565
566            let config = format!("http.followRedirects={follow_redirects}");
567            assert!(args.windows(2).any(|pair| pair == ["-c", config.as_str()]));
568        }
569    }
570
571    #[test]
572    fn a_clone_that_stops_receiving_data_is_cut_after_ten_minutes() {
573        let opts = opts(CloneShape::SingleBranch);
574        let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
575
576        let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
577
578        assert!(args
579            .windows(2)
580            .any(|pair| pair == ["-c", "http.lowSpeedLimit=1000"]));
581        assert!(args
582            .windows(2)
583            .any(|pair| pair == ["-c", "http.lowSpeedTime=600"]));
584    }
585
586    #[tokio::test]
587    async fn a_pin_names_the_host_and_port_git_dials_and_only_the_checked_address() {
588        for (url, pin) in [
589            ("https://8.8.8.8/r.git", "8.8.8.8:443:8.8.8.8"),
590            ("http://8.8.8.8/r.git", "8.8.8.8:80:8.8.8.8"),
591            ("https://8.8.8.8:8443/r.git", "8.8.8.8:8443:8.8.8.8"),
592            ("https://u:p@8.8.8.8/r.git", "8.8.8.8:443:8.8.8.8"),
593            (
594                "https://[2606:4700::1111]/r.git",
595                "[2606:4700::1111]:443:[2606:4700::1111]",
596            ),
597        ] {
598            assert_eq!(public_pin(url).await.unwrap(), pin, "{url}");
599        }
600    }
601
602    #[test]
603    fn a_pin_lists_each_address_once_in_the_order_resolved() {
604        let ips = [
605            "8.8.8.8",
606            "2001:4860:4860::8888",
607            "8.8.8.8",
608            "8.8.4.4",
609            "2001:4860:4860::8888",
610        ]
611        .map(|ip| ip.parse().unwrap());
612
613        assert_eq!(
614            pin_entry("dns.google", 443, &ips),
615            "dns.google:443:8.8.8.8,[2001:4860:4860::8888],8.8.4.4"
616        );
617    }
618
619    #[tokio::test]
620    async fn a_host_that_cannot_be_read_as_written_gets_no_pin() {
621        for url in [
622            "https:///r.git",
623            "https://%31%30.0.0.1/r.git",
624            "https://a..b/r.git",
625            "https://[::1%25lo]/r.git",
626        ] {
627            let pin = public_pin(url).await;
628
629            assert!(matches!(pin, Err(HostError::Unparsable)), "{url}: {pin:?}");
630        }
631    }
632
633    #[tokio::test]
634    async fn a_local_host_gets_no_pin() {
635        for (url, local) in [
636            ("https://10.0.0.1/r.git", LocalAddress::LocalNetwork),
637            ("https://127.0.0.1/r.git", LocalAddress::Loopback),
638            ("https://localhost/r.git", LocalAddress::Loopback),
639        ] {
640            let pin = public_pin(url).await;
641
642            assert!(
643                matches!(pin, Err(HostError::Local(found)) if found == local),
644                "{url}: {pin:?}"
645            );
646        }
647    }
648}