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
11const 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
28const ONLY_ASSUME_ROLE: &str = r#"{
32 "Version": "2012-10-17",
33 "Statement": [{
34 "Effect": "Allow",
35 "Action": "sts:AssumeRole",
36 "Resource": "*"
37 }]
38}"#;
39
40const 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(®ion, None).await.assume(&first).await?;
75 if let Some(hop) = then {
76 let chained = Sts::signed_as(®ion, Some(&creds)).await;
77 creds = chained.assume(&hop).await?;
78 }
79
80 Ok(creds)
81}
82
83struct 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 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
141fn 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 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 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 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 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}