Skip to main content

fluidattacks_core_git/
aws.rs

1use std::time::Duration;
2
3use aws_config::timeout::TimeoutConfig;
4use aws_sdk_sts::error::{ConnectorError, DisplayErrorContext, ProvideErrorMetadata, SdkError};
5use aws_sdk_sts::operation::assume_role::{AssumeRoleError, AssumeRoleOutput};
6use regex::Regex;
7use uuid::Uuid;
8
9use crate::errors::StsError;
10
11/// STS answers in tenths of a second; this leaves room for a slow one, as git's
12/// ls-remote does. Without one the SDK waits as long as STS holds the connection open.
13const READ_TIMEOUT: Duration = Duration::from_secs(20);
14
15pub struct AwsTempCreds {
16    pub access_key: String,
17    pub secret_key: String,
18    pub session_token: String,
19    pub region: String,
20}
21
22struct Hop<'a> {
23    role_arn: &'a str,
24    external_id: Option<&'a str>,
25    policy: Option<&'static str>,
26}
27
28/// The intermediate session may only assume a role, so a chained caller never
29/// holds the intermediate role's other permissions. The resource stays open
30/// because a policy would match the client's ARN as typed, case included.
31const ONLY_ASSUME_ROLE: &str = r#"{
32    "Version": "2012-10-17",
33    "Statement": [{
34        "Effect": "Allow",
35        "Action": "sts:AssumeRole",
36        "Resource": "*"
37    }]
38}"#;
39
40/// Chained when the caller is not the principal the client's trust policy pins.
41const fn chain<'a>(
42    arn: &'a str,
43    external_id: &'a str,
44    via: Option<&'a str>,
45) -> (Hop<'a>, Option<Hop<'a>>) {
46    let client = Hop {
47        role_arn: arn,
48        external_id: Some(external_id),
49        policy: None,
50    };
51
52    match via {
53        Some(intermediate) => (
54            Hop {
55                role_arn: intermediate,
56                external_id: None,
57                policy: Some(ONLY_ASSUME_ROLE),
58            },
59            Some(client),
60        ),
61        None => (client, None),
62    }
63}
64
65pub async fn assume_role(
66    arn: &str,
67    external_id: &str,
68    url: &str,
69    via: Option<&str>,
70) -> Result<AwsTempCreds, StsError> {
71    let region = extract_region(url);
72    let (first, then) = chain(arn, external_id, via);
73
74    let mut creds = Sts::signed_as(&region, None).await.assume(&first).await?;
75    if let Some(hop) = then {
76        let chained = Sts::signed_as(&region, Some(&creds)).await;
77        creds = chained.assume(&hop).await?;
78    }
79
80    Ok(creds)
81}
82
83/// STS in the repository's region, signed by the worker or by the previous hop's session.
84struct Sts {
85    client: aws_sdk_sts::Client,
86    region: String,
87}
88
89impl Sts {
90    async fn signed_as(region: &str, from: Option<&AwsTempCreds>) -> Self {
91        Self {
92            client: aws_sdk_sts::Client::new(&config(region, from).load().await),
93            region: region.to_owned(),
94        }
95    }
96
97    async fn assume(&self, hop: &Hop<'_>) -> Result<AwsTempCreds, StsError> {
98        let mut request = self
99            .client
100            .assume_role()
101            .role_arn(hop.role_arn)
102            .role_session_name(format!("session-{}", Uuid::new_v4()));
103
104        if let Some(external_id) = hop.external_id {
105            request = request.external_id(external_id);
106        }
107
108        if let Some(policy) = hop.policy {
109            request = request.policy(policy);
110        }
111
112        let resp = request
113            .send()
114            .await
115            .map_err(|error| refusal(hop.role_arn, &error))?;
116
117        session(hop.role_arn, &resp, &self.region)
118    }
119}
120
121fn config(region: &str, from: Option<&AwsTempCreds>) -> aws_config::ConfigLoader {
122    // Only the read timeout: the SDK's connect timeout stays.
123    let timeouts = TimeoutConfig::builder().read_timeout(READ_TIMEOUT).build();
124    let mut config = aws_config::defaults(aws_config::BehaviorVersion::latest())
125        .region(aws_config::Region::new(region.to_owned()))
126        .timeout_config(timeouts);
127
128    if let Some(session) = from {
129        config = config.credentials_provider(aws_sdk_sts::config::Credentials::new(
130            session.access_key.clone(),
131            session.secret_key.clone(),
132            Some(session.session_token.clone()),
133            None,
134            "fluidattacks-core-chain",
135        ));
136    }
137
138    config
139}
140
141// botocore says "Unknown" for a code or message STS left out.
142fn refusal<R: std::fmt::Debug>(arn: &str, error: &SdkError<AssumeRoleError, R>) -> StsError {
143    let arn = arn.to_owned();
144    if let SdkError::ServiceError(refused) = error {
145        return StsError::Refused {
146            arn,
147            code: refused.err().code().unwrap_or("Unknown").to_owned(),
148            message: refused.err().message().unwrap_or("Unknown").to_owned(),
149        };
150    }
151
152    let reason = DisplayErrorContext(error).to_string();
153    let SdkError::DispatchFailure(failure) = error else {
154        return StsError::Unreachable { arn, reason };
155    };
156    let Some(connector) = failure.as_connector_error() else {
157        return StsError::Unreachable { arn, reason };
158    };
159
160    // Told apart as botocore tells its classes apart, since integrates catches those.
161    let timed_out = connector.is_timeout();
162    if never_connected(connector) {
163        return if timed_out {
164            StsError::ConnectTimeout { arn, reason }
165        } else {
166            StsError::NoConnection { arn, reason }
167        };
168    }
169    if timed_out {
170        StsError::ReadTimeout { arn, reason }
171    } else if hung_up(connector) {
172        StsError::ConnectionClosed { arn, reason }
173    } else {
174        StsError::Unreachable { arn, reason }
175    }
176}
177
178fn never_connected(connector: &ConnectorError) -> bool {
179    causes(connector).any(|cause| {
180        cause
181            .downcast_ref::<hyper_util::client::legacy::Error>()
182            .is_some_and(hyper_util::client::legacy::Error::is_connect)
183    })
184}
185
186fn hung_up(connector: &ConnectorError) -> bool {
187    connector.is_io()
188        || causes(connector).any(|cause| {
189            cause
190                .downcast_ref::<hyper::Error>()
191                .is_some_and(|hyper| hyper.is_incomplete_message() || hyper.is_closed())
192        })
193}
194
195fn causes<'a>(
196    error: &'a (dyn std::error::Error + 'static),
197) -> impl Iterator<Item = &'a (dyn std::error::Error + 'static)> {
198    std::iter::successors(Some(error), |current| current.source())
199}
200
201fn session(arn: &str, resp: &AssumeRoleOutput, region: &str) -> Result<AwsTempCreds, StsError> {
202    let no_credentials = || StsError::NoCredentials {
203        arn: arn.to_owned(),
204    };
205    let creds = resp.credentials().ok_or_else(no_credentials)?;
206
207    let access_key = creds.access_key_id().to_owned();
208    let secret_key = creds.secret_access_key().to_owned();
209    let session_token = creds.session_token().to_owned();
210
211    if access_key.is_empty() || secret_key.is_empty() {
212        return Err(no_credentials());
213    }
214
215    Ok(AwsTempCreds {
216        access_key,
217        secret_key,
218        session_token,
219        region: region.to_owned(),
220    })
221}
222
223fn extract_region(url: &str) -> String {
224    [
225        r"codecommit::([a-z0-9-]+)://",
226        r"git-codecommit\.([a-z0-9-]+)\.amazonaws\.com",
227    ]
228    .into_iter()
229    .find_map(|pattern| {
230        let region = Regex::new(pattern).ok()?.captures(url)?.get(1)?;
231        Some(region.as_str().to_owned())
232    })
233    .unwrap_or_else(|| "us-east-1".to_owned())
234}
235
236#[cfg(test)]
237mod tests {
238    use aws_sdk_sts::error::{ErrorMetadata, SdkError};
239    use aws_sdk_sts::operation::assume_role::{AssumeRoleError, AssumeRoleOutput};
240    use aws_sdk_sts::primitives::DateTime;
241    use aws_sdk_sts::types::Credentials;
242    use aws_sdk_sts::Client;
243    use std::io::{BufRead, BufReader, Read, Write};
244    use std::net::{TcpListener, TcpStream};
245    use std::time::Duration;
246
247    use super::{chain, config, extract_region, refusal, session, Hop, Sts, TimeoutConfig};
248    use crate::errors::StsError;
249
250    const ROLE: &str = "arn:aws:iam::1:role/client";
251
252    #[test]
253    fn a_refusal_carries_the_code_and_message_sts_answered() {
254        let refused = AssumeRoleError::generic(
255            ErrorMetadata::builder()
256                .code("AccessDenied")
257                .message("not authorized to perform sts:AssumeRole")
258                .build(),
259        );
260
261        let error = refusal(ROLE, &SdkError::service_error(refused, ()));
262
263        assert_eq!(
264            error.to_string(),
265            "STS refused the role arn:aws:iam::1:role/client: AccessDenied: not authorized to perform sts:AssumeRole"
266        );
267    }
268
269    #[test]
270    fn an_sts_that_never_answered_is_not_a_refusal() {
271        let error = refusal(
272            ROLE,
273            &SdkError::<AssumeRoleError, ()>::timeout_error("timed out"),
274        );
275
276        assert!(matches!(error, StsError::Unreachable { .. }), "{error:?}");
277    }
278
279    #[test]
280    fn an_answer_without_a_usable_key_is_no_session() {
281        let keys = |access: &str, secret: &str| {
282            Credentials::builder()
283                .access_key_id(access)
284                .secret_access_key(secret)
285                .session_token("a-token")
286                .expiration(DateTime::from_secs(0))
287                .build()
288                .unwrap()
289        };
290
291        for answer in [
292            AssumeRoleOutput::builder().build(),
293            AssumeRoleOutput::builder()
294                .credentials(keys("", "a-secret-key"))
295                .build(),
296            AssumeRoleOutput::builder()
297                .credentials(keys("an-access-key", ""))
298                .build(),
299        ] {
300            let outcome = session(ROLE, &answer, "us-east-1");
301
302            assert!(
303                matches!(outcome, Err(StsError::NoCredentials { .. })),
304                "{answer:?}"
305            );
306        }
307    }
308
309    #[test]
310    fn the_region_comes_from_the_url_or_falls_back_to_us_east_1() {
311        for (url, region) in [
312            ("codecommit::eu-west-1://a-repo", "eu-west-1"),
313            (
314                "https://git-codecommit.sa-east-1.amazonaws.com/v1/repos/a-repo",
315                "sa-east-1",
316            ),
317            ("codecommit://a-repo", "us-east-1"),
318        ] {
319            assert_eq!(extract_region(url), region);
320        }
321    }
322
323    #[test]
324    fn one_call_takes_the_client_role_with_its_external_id() {
325        let (first, then) = chain("arn:aws:iam::1:role/client", "an-external-id", None);
326
327        assert_eq!(first.role_arn, "arn:aws:iam::1:role/client");
328        assert_eq!(first.external_id, Some("an-external-id"));
329        assert_eq!(first.policy, None);
330        assert!(then.is_none());
331    }
332
333    #[test]
334    fn a_chain_takes_the_intermediate_role_first_and_without_the_external_id() {
335        let (first, then) = chain(
336            "arn:aws:iam::1:role/client",
337            "an-external-id",
338            Some("arn:aws:iam::2:role/intermediate"),
339        );
340
341        assert_eq!(first.role_arn, "arn:aws:iam::2:role/intermediate");
342        assert_eq!(first.external_id, None);
343
344        let client = then.expect("the client role is still taken");
345        assert_eq!(client.role_arn, "arn:aws:iam::1:role/client");
346        assert_eq!(client.external_id, Some("an-external-id"));
347        assert_eq!(client.policy, None);
348    }
349
350    #[test]
351    fn a_chain_lets_the_intermediate_session_only_assume_a_role() {
352        let (first, _) = chain(
353            "arn:aws:iam::1:role/client",
354            "an-external-id",
355            Some("arn:aws:iam::2:role/intermediate"),
356        );
357
358        let policy: serde_json::Value =
359            serde_json::from_str(first.policy.expect("the intermediate session is narrowed"))
360                .expect("the policy is json");
361        assert_eq!(
362            policy.get("Statement"),
363            Some(&serde_json::json!([{
364                "Effect": "Allow",
365                "Action": "sts:AssumeRole",
366                "Resource": "*",
367            }]))
368        );
369    }
370
371    // An STS on a local port that does `answer` with each request it reads.
372    fn sts(answer: fn(TcpStream)) -> String {
373        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
374        let port = listener.local_addr().expect("addr").port();
375        std::thread::spawn(move || {
376            for stream in listener.incoming().flatten() {
377                std::thread::spawn(move || serve(stream, answer));
378            }
379        });
380        format!("http://127.0.0.1:{port}")
381    }
382
383    fn serve(stream: TcpStream, answer: fn(TcpStream)) {
384        read_request(&stream);
385        answer(stream);
386    }
387
388    fn read_request(stream: &TcpStream) {
389        let mut reader = BufReader::new(stream);
390        let mut length = 0;
391        let mut line = String::new();
392        while reader.read_line(&mut line).is_ok_and(|read| read > 2) {
393            if let Some(value) = line.to_lowercase().strip_prefix("content-length: ") {
394                length = value.trim().parse().unwrap_or(0);
395            }
396            line.clear();
397        }
398        let _ = reader.read_exact(&mut vec![0; length]);
399    }
400
401    async fn ask_sts(
402        endpoint: &str,
403        hop: &Hop<'_>,
404        signed: bool,
405    ) -> Result<super::AwsTempCreds, StsError> {
406        let quick = TimeoutConfig::builder()
407            .read_timeout(Duration::from_millis(200))
408            .build();
409        let mut loader = config("us-east-1", None)
410            .endpoint_url(endpoint)
411            .timeout_config(quick);
412        loader = if signed {
413            loader.credentials_provider(aws_sdk_sts::config::Credentials::new(
414                "a-key", "a-secret", None, None, "test",
415            ))
416        } else {
417            loader.no_credentials()
418        };
419        let sts = Sts {
420            client: Client::new(&loader.load().await),
421            region: "us-east-1".to_owned(),
422        };
423        sts.assume(hop).await
424    }
425
426    fn client_role() -> Hop<'static> {
427        chain(ROLE, "an-external-id", None).0
428    }
429
430    #[tokio::test]
431    async fn sts_has_twenty_seconds_to_answer() {
432        let sts = Sts::signed_as("us-east-1", None).await;
433
434        let read_timeout = sts
435            .client
436            .config()
437            .timeout_config()
438            .and_then(TimeoutConfig::read_timeout);
439
440        assert_eq!(read_timeout, Some(Duration::from_secs(20)));
441    }
442
443    #[tokio::test]
444    async fn an_sts_that_keeps_the_request_unanswered_is_a_read_timeout() {
445        let endpoint = sts(|_stream| std::thread::sleep(Duration::from_secs(5)));
446
447        let outcome = ask_sts(&endpoint, &client_role(), true).await;
448
449        assert!(
450            matches!(outcome, Err(StsError::ReadTimeout { .. })),
451            "{:?}",
452            outcome.err()
453        );
454    }
455
456    #[tokio::test]
457    async fn an_sts_that_closes_the_connection_unanswered_is_connection_closed() {
458        let endpoint = sts(drop);
459
460        let outcome = ask_sts(&endpoint, &client_role(), true).await;
461
462        assert!(
463            matches!(outcome, Err(StsError::ConnectionClosed { .. })),
464            "{:?}",
465            outcome.err()
466        );
467    }
468
469    #[tokio::test]
470    async fn an_sts_that_resets_the_connection_unanswered_is_connection_closed() {
471        let endpoint = sts(|stream| {
472            // With no linger, closing sends a reset instead of an orderly close.
473            rustix::net::sockopt::set_socket_linger(&stream, Some(Duration::ZERO)).expect("linger");
474        });
475
476        let outcome = ask_sts(&endpoint, &client_role(), true).await;
477
478        assert!(
479            matches!(outcome, Err(StsError::ConnectionClosed { .. })),
480            "{:?}",
481            outcome.err()
482        );
483    }
484
485    #[tokio::test]
486    async fn a_port_nobody_listens_on_is_no_connection() {
487        let closed = TcpListener::bind("127.0.0.1:0").expect("bind");
488        let endpoint = format!("http://{}", closed.local_addr().expect("addr"));
489        drop(closed);
490
491        let outcome = ask_sts(&endpoint, &client_role(), true).await;
492
493        assert!(
494            matches!(outcome, Err(StsError::NoConnection { .. })),
495            "{:?}",
496            outcome.err()
497        );
498    }
499
500    #[tokio::test]
501    async fn a_caller_without_credentials_to_sign_with_is_unreachable() {
502        let endpoint = sts(drop);
503
504        let outcome = ask_sts(&endpoint, &client_role(), false).await;
505
506        assert!(
507            matches!(outcome, Err(StsError::Unreachable { .. })),
508            "{:?}",
509            outcome.err()
510        );
511    }
512
513    #[tokio::test]
514    async fn a_role_below_the_sts_limits_is_left_for_sts_to_refuse() {
515        // botocore refused these before asking; the SDK sends them as they are.
516        let endpoint = sts(|mut stream| {
517            let body = "<ErrorResponse><Error><Code>ValidationError</Code>\
518                        <Message>too short</Message></Error></ErrorResponse>";
519            let _ = write!(
520                stream,
521                "HTTP/1.1 400 Bad Request\r\nContent-Type: text/xml\r\n\
522                 Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
523                body.len()
524            );
525        });
526        let short = Hop {
527            role_arn: "x",
528            external_id: Some("a"),
529            policy: None,
530        };
531
532        let outcome = ask_sts(&endpoint, &short, true).await;
533
534        let Err(StsError::Refused { code, .. }) = outcome else {
535            panic!("{:?}", outcome.err());
536        };
537        assert_eq!(code, "ValidationError");
538    }
539}