1use axum::body::Body;
2use axum::extract::{ConnectInfo, Extension, Query};
3use axum::http::{Request, StatusCode};
4use axum::response::Response;
5use bytes::Bytes;
6use std::collections::HashMap;
7use std::net::SocketAddr;
8use std::sync::Arc;
9
10use crate::auth::{
11 is_root_bypass, ConditionContext, CredentialResolver, IamMode, IamPolicyEvaluator,
12 InternalCaller, Principal, PrincipalType, ResourcePolicyProvider,
13};
14use crate::protocol::{self, AwsProtocol};
15use crate::registry::ServiceRegistry;
16use crate::service::{AwsRequest, ResponseBody};
17
18#[derive(Clone, Copy, Debug)]
21struct PinnedService(&'static str);
22
23pub async fn dispatch_to_service(
35 service: &'static str,
36 registry: Arc<ServiceRegistry>,
37 config: Arc<DispatchConfig>,
38 mut request: Request<Body>,
39) -> Response<Body> {
40 let remote_addr = request
41 .extensions()
42 .get::<ConnectInfo<SocketAddr>>()
43 .map(|c| c.0)
44 .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
45 let query = match Query::<HashMap<String, String>>::try_from_uri(request.uri()) {
46 Ok(q) => q,
47 Err(e) => {
48 return build_error_response(
49 StatusCode::BAD_REQUEST,
50 "InvalidArgument",
51 &format!("Invalid query string: {e}"),
52 &uuid::Uuid::new_v4().to_string(),
53 AwsProtocol::Rest,
54 )
55 }
56 };
57 request.extensions_mut().insert(PinnedService(service));
58 dispatch(
59 ConnectInfo(remote_addr),
60 Extension(registry),
61 Extension(config),
62 query,
63 request,
64 )
65 .await
66}
67
68const REQUEST_COMPRESSION_SERVICES: &[&str] = &["monitoring"];
73
74fn decode_request_compression(
77 headers: &http::HeaderMap,
78 rpc_v2_cbor: Option<&protocol::DetectedRequest>,
79 body: Bytes,
80) -> Result<Bytes, (String, AwsProtocol)> {
81 let gzipped = headers
82 .get_all(http::header::CONTENT_ENCODING)
83 .iter()
84 .filter_map(|v| v.to_str().ok())
85 .flat_map(|v| v.split(','))
86 .any(|enc| enc.trim().eq_ignore_ascii_case("gzip"));
87 if !gzipped || body.is_empty() {
88 return Ok(body);
89 }
90 let target = headers
91 .get("x-amz-target")
92 .and_then(|v| v.to_str().ok())
93 .and_then(protocol::parse_amz_target);
94 let (service, protocol) = if let Some(d) = rpc_v2_cbor {
95 (Some(d.service.clone()), AwsProtocol::RpcV2Cbor)
96 } else if let Some(d) = target {
97 (Some(d.service), AwsProtocol::Json)
98 } else {
99 (
100 protocol::extract_service_from_auth(headers),
101 AwsProtocol::Query,
102 )
103 };
104 if !service.is_some_and(|s| REQUEST_COMPRESSION_SERVICES.contains(&s.as_str())) {
105 return Ok(body);
106 }
107 use std::io::Read;
108 let limit = max_request_body_bytes() as u64;
109 let mut out = Vec::new();
110 let read = flate2::read::MultiGzDecoder::new(body.as_ref())
111 .take(limit + 1)
112 .read_to_end(&mut out);
113 match read {
114 Ok(_) if out.len() as u64 > limit => {
115 Err(("Decompressed request body too large".to_string(), protocol))
116 }
117 Ok(_) => Ok(Bytes::from(out)),
118 Err(e) => Err((
119 format!("Unable to decompress gzip request body: {e}"),
120 protocol,
121 )),
122 }
123}
124
125const AWS_JSON_1_0: &str = "application/x-amz-json-1.0";
127const AWS_JSON_1_1: &str = "application/x-amz-json-1.1";
129
130pub async fn dispatch(
132 ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
133 Extension(registry): Extension<Arc<ServiceRegistry>>,
134 Extension(config): Extension<Arc<DispatchConfig>>,
135 Query(query_params): Query<HashMap<String, String>>,
136 request: Request<Body>,
137) -> Response<Body> {
138 let json_1_0 = request
139 .headers()
140 .get(http::header::CONTENT_TYPE)
141 .and_then(|v| v.to_str().ok())
142 .is_some_and(|ct| ct.trim().eq_ignore_ascii_case(AWS_JSON_1_0));
143 let mut response = dispatch_inner(remote_addr, registry, config, query_params, request).await;
144 if json_1_0 {
145 answer_in_json_1_0(&mut response);
146 }
147 response
148}
149
150fn answer_in_json_1_0(response: &mut Response<Body>) {
155 let is_1_1 = response
156 .headers()
157 .get(http::header::CONTENT_TYPE)
158 .and_then(|v| v.to_str().ok())
159 .is_some_and(|ct| ct.eq_ignore_ascii_case(AWS_JSON_1_1));
160 if is_1_1 {
161 response.headers_mut().insert(
162 http::header::CONTENT_TYPE,
163 http::HeaderValue::from_static(AWS_JSON_1_0),
164 );
165 }
166}
167
168async fn dispatch_inner(
169 remote_addr: SocketAddr,
170 registry: Arc<ServiceRegistry>,
171 config: Arc<DispatchConfig>,
172 query_params: HashMap<String, String>,
173 request: Request<Body>,
174) -> Response<Body> {
175 let remote_addr = Some(remote_addr);
176 let request_id = uuid::Uuid::new_v4().to_string();
177
178 let (mut parts, body) = request.into_parts();
179 let pinned = parts.extensions.get::<PinnedService>().map(|p| p.0);
182
183 let rpc_v2_cbor = if pinned.is_none() {
193 protocol::detect_rpc_v2_cbor(&parts.headers, parts.uri.path())
194 } else {
195 None
196 };
197 let stream_route = streaming_route(
198 &parts.method,
199 parts.uri.path(),
200 &parts.headers,
201 &query_params,
202 );
203 let header_only = protocol::detect_service_headers_only(&parts.headers, &query_params);
204 let stream_dispatch = match (&stream_route, &header_only) {
205 _ if pinned.is_some() => None,
208 _ if rpc_v2_cbor.is_some() => None,
209 (Some(sr), Some(detected)) if sr.0 == detected.service => Some(detected.clone()),
212 (Some((service, _)), None) if *service == "ecr" => Some(protocol::DetectedRequest {
218 service: "ecr".to_string(),
219 action: String::new(),
220 protocol: AwsProtocol::Rest,
221 }),
222 _ => None,
223 };
224
225 let (body_bytes, body_stream) = if stream_dispatch.is_some() {
226 (Bytes::new(), Some(body))
227 } else {
228 let max_body_bytes = max_request_body_bytes();
233 match axum::body::to_bytes(body, max_body_bytes).await {
234 Ok(b) => (b, None),
235 Err(_) => {
236 return build_error_response(
237 StatusCode::PAYLOAD_TOO_LARGE,
238 "RequestEntityTooLarge",
239 "Request body too large",
240 &request_id,
241 AwsProtocol::Query,
242 );
243 }
244 }
245 };
246
247 let wire_body = body_bytes.clone();
252 let body_bytes = if pinned.is_none() && stream_dispatch.is_none() {
253 match decode_request_compression(&parts.headers, rpc_v2_cbor.as_ref(), body_bytes) {
254 Ok(b) => b,
255 Err((message, protocol)) => {
256 return build_error_response(
257 StatusCode::BAD_REQUEST,
258 "SerializationException",
259 &message,
260 &request_id,
261 protocol,
262 );
263 }
264 }
265 } else {
266 body_bytes
267 };
268
269 let detected = if let Some(service) = pinned {
271 protocol::DetectedRequest {
272 service: service.to_string(),
273 action: String::new(),
274 protocol: AwsProtocol::Rest,
275 }
276 } else if let Some(d) = rpc_v2_cbor {
277 d
278 } else if let Some(d) = stream_dispatch {
279 d
280 } else {
281 match protocol::detect_service(&parts.headers, &query_params, &body_bytes) {
282 Some(d) => d,
283 None => {
284 if let Some(target) = parts
290 .headers
291 .get("x-amz-target")
292 .and_then(|v| v.to_str().ok())
293 {
294 return build_error_response(
295 StatusCode::BAD_REQUEST,
296 "UnknownOperationException",
297 &format!("The operation {target} is not recognized."),
298 &request_id,
299 AwsProtocol::Json,
300 );
301 }
302 if parts.method == http::Method::OPTIONS && is_execute_api_host(&parts.headers) {
308 protocol::DetectedRequest {
309 service: "apigateway".to_string(),
310 action: String::new(),
311 protocol: AwsProtocol::RestJson,
312 }
313 } else if parts.method == http::Method::OPTIONS {
314 protocol::DetectedRequest {
315 service: "s3".to_string(),
316 action: String::new(),
317 protocol: AwsProtocol::Rest,
318 }
319 } else if parts.uri.path() == "/v2" || parts.uri.path().starts_with("/v2/") {
320 protocol::DetectedRequest {
324 service: "ecr".to_string(),
325 action: String::new(),
326 protocol: AwsProtocol::Rest,
327 }
328 } else if let Some(bucket) = anonymous_s3_bucket(&parts.uri, &config) {
329 tracing::debug!(bucket = %bucket, "routing unsigned request to S3 (existing bucket)");
336 protocol::DetectedRequest {
337 service: "s3".to_string(),
338 action: String::new(),
339 protocol: AwsProtocol::Rest,
340 }
341 } else if !parts.uri.path().starts_with("/_")
342 || parts.uri.path().starts_with("/_aws/execute-api/")
343 {
344 protocol::DetectedRequest {
351 service: "apigateway".to_string(),
352 action: String::new(),
353 protocol: AwsProtocol::RestJson,
354 }
355 } else {
356 return build_error_response(
357 StatusCode::BAD_REQUEST,
358 "MissingAction",
359 "Could not determine target service or action from request",
360 &request_id,
361 AwsProtocol::Query,
362 );
363 }
364 }
365 }
366 };
367
368 let detected = if detected.service == "bedrock" {
372 match bedrock_agent_service_for(&parts.method, parts.uri.path()) {
373 Some(service) => protocol::DetectedRequest {
374 service: service.to_string(),
375 ..detected
376 },
377 None => detected,
378 }
379 } else {
380 detected
381 };
382
383 let detected = if detected.service == "rds" && user_agent_indicates_docdb(&parts.headers) {
392 protocol::DetectedRequest {
393 service: "docdb".to_string(),
394 ..detected
395 }
396 } else {
397 detected
398 };
399
400 let detected = if detected.service == "rds" && user_agent_indicates_neptune(&parts.headers) {
409 protocol::DetectedRequest {
410 service: "neptune".to_string(),
411 ..detected
412 }
413 } else {
414 detected
415 };
416
417 let service = match registry.get(&detected.service) {
419 Some(s) => s,
420 None => {
421 return build_error_response(
422 detected.protocol.error_status(),
423 "UnknownService",
424 &format!("Service '{}' is not available", detected.service),
425 &request_id,
426 ErrorEnvelope::for_request(&detected, &parts.headers),
427 );
428 }
429 };
430
431 let auth_header = parts
433 .headers
434 .get("authorization")
435 .and_then(|v| v.to_str().ok())
436 .unwrap_or("");
437 let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
438 let presigned_info = if header_info.is_none() {
439 fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
441 } else {
442 None
443 };
444 let sigv4_info = header_info.or(presigned_info);
445 let access_key_id = sigv4_info
451 .as_ref()
452 .map(|info| info.access_key.clone())
453 .or_else(|| sigv2_presigned_access_key(&query_params));
454
455 let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
461
462 let region = sigv4_info
463 .map(|info| info.region)
464 .or_else(|| host_info.as_ref().map(|h| h.region.clone()))
465 .or_else(|| extract_region_from_user_agent(&parts.headers))
466 .unwrap_or_else(|| config.region.clone());
467
468 let caller_akid = access_key_id.as_deref().unwrap_or("");
474 let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
475 config
476 .credential_resolver
477 .as_ref()
478 .and_then(|r| r.resolve(caller_akid))
479 } else {
480 None
481 };
482 let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
483 let caller_session_policies = resolved
484 .as_ref()
485 .map(|r| r.session_policies.clone())
486 .unwrap_or_default();
487
488 let is_fully_anonymous = auth_header.is_empty()
504 && !query_params.contains_key("X-Amz-Credential")
505 && sigv2_presigned_access_key(&query_params).is_none();
506 if config.verify_sigv4
507 && !is_fully_anonymous
508 && !is_root_bypass(caller_akid)
509 && config.credential_resolver.is_some()
510 {
511 let amz_date = parts
512 .headers
513 .get("x-amz-date")
514 .and_then(|v| v.to_str().ok());
515 let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
516 .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
517 let parsed = match parsed {
518 Some(p) => p,
519 None => {
520 return build_error_response(
521 StatusCode::FORBIDDEN,
522 "IncompleteSignature",
523 "Request is missing or has a malformed AWS Signature",
524 &request_id,
525 ErrorEnvelope::for_request(&detected, &parts.headers),
526 );
527 }
528 };
529 let resolved_for_verify = match resolved.as_ref() {
530 Some(r) => r,
531 None => {
532 return unresolved_credential_response(
533 &config,
534 caller_akid,
535 &request_id,
536 ErrorEnvelope::for_request(&detected, &parts.headers),
537 );
538 }
539 };
540 let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
541 let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
542 let verify_req = fakecloud_aws::sigv4::VerifyRequest {
543 method: parts.method.as_str(),
544 path: parts.uri.path(),
545 query: &raw_query_for_verify,
546 headers: &headers_vec,
547 body: &wire_body,
548 };
549 match fakecloud_aws::sigv4::verify(
550 &parsed,
551 &verify_req,
552 &resolved_for_verify.secret_access_key,
553 chrono::Utc::now(),
554 ) {
555 Ok(()) => {
556 if !parsed.is_presigned && detected.service != "s3" {
572 if let Some(signed_hash) = parts
573 .headers
574 .get("x-amz-content-sha256")
575 .and_then(|v| v.to_str().ok())
576 .filter(|h| is_hex_sha256(h))
577 {
578 if sha256_hex_lower(&wire_body) != signed_hash {
579 return build_error_response(
580 StatusCode::FORBIDDEN,
581 "SignatureDoesNotMatch",
582 "The request signature we calculated does not match the signature you provided",
583 &request_id,
584 ErrorEnvelope::for_request(&detected, &parts.headers),
585 );
586 }
587 }
588 }
589 }
590 Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
591 return build_error_response(
592 StatusCode::FORBIDDEN,
593 "RequestTimeTooSkewed",
594 "The difference between the request time and the current time is too large",
595 &request_id,
596 ErrorEnvelope::for_request(&detected, &parts.headers),
597 );
598 }
599 Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
600 return build_error_response(
601 StatusCode::FORBIDDEN,
602 "IncompleteSignature",
603 &format!("Invalid x-amz-date: {msg}"),
604 &request_id,
605 ErrorEnvelope::for_request(&detected, &parts.headers),
606 );
607 }
608 Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
609 return build_error_response(
610 StatusCode::FORBIDDEN,
611 "IncompleteSignature",
612 &format!("Malformed SigV4 signature: {msg}"),
613 &request_id,
614 ErrorEnvelope::for_request(&detected, &parts.headers),
615 );
616 }
617 Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
618 return build_error_response(
619 StatusCode::FORBIDDEN,
620 "SignatureDoesNotMatch",
621 "The request signature we calculated does not match the signature you provided",
622 &request_id,
623 ErrorEnvelope::for_request(&detected, &parts.headers),
624 );
625 }
626 Err(fakecloud_aws::sigv4::SigV4Error::PresignedUrlExpired { .. }) => {
627 return build_error_response(
628 StatusCode::FORBIDDEN,
629 "AccessDenied",
630 "Request has expired",
631 &request_id,
632 ErrorEnvelope::for_request(&detected, &parts.headers),
633 );
634 }
635 Err(fakecloud_aws::sigv4::SigV4Error::InvalidPresignExpires(_)) => {
636 return build_error_response(
637 StatusCode::BAD_REQUEST,
638 "AuthorizationQueryParametersError",
639 "X-Amz-Expires must be a number between 1 and 604800 seconds",
640 &request_id,
641 ErrorEnvelope::for_request(&detected, &parts.headers),
642 );
643 }
644 }
645 }
646
647 if detected.service == "s3" && query_params.contains_key("X-Amz-Credential") {
652 hoist_presigned_query_headers(&mut parts.headers, &query_params);
653 }
654
655 let wire_path = parts.uri.path();
660 let path = if detected.service == "s3" {
661 s3_routing_path(
662 wire_path,
663 host_info.as_ref().and_then(|h| h.bucket.as_deref()),
664 )
665 } else {
666 wire_path.to_string()
667 };
668 let raw_query = parts.uri.query().unwrap_or("").to_string();
669 let path_segments = crate::path::split_path_segments(&path);
673
674 let body_bytes = if detected.protocol == AwsProtocol::RpcV2Cbor {
678 match crate::cbor::decode_to_json(&body_bytes) {
679 Ok(json) => Bytes::from(json.to_string()),
680 Err(e) => {
681 return build_error_response(
682 StatusCode::BAD_REQUEST,
683 "SerializationException",
684 &format!("Unable to decode CBOR request body: {e}"),
685 &request_id,
686 AwsProtocol::RpcV2Cbor,
687 );
688 }
689 }
690 } else {
691 body_bytes
692 };
693
694 if detected.protocol == AwsProtocol::Json
696 && !body_bytes.is_empty()
697 && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
698 {
699 return build_error_response(
700 StatusCode::BAD_REQUEST,
701 "SerializationException",
702 "Start of structure or map found where not expected",
703 &request_id,
704 AwsProtocol::Json,
705 );
706 }
707
708 let mut all_params = query_params;
711 if matches!(
712 detected.protocol,
713 AwsProtocol::Query | AwsProtocol::Ec2Query
714 ) {
715 let body_params = protocol::parse_query_body(&body_bytes);
716 for (k, v) in body_params {
717 all_params.entry(k).or_insert(v);
718 }
719 }
720
721 if matches!(
726 detected.protocol,
727 AwsProtocol::Json | AwsProtocol::RpcV2Cbor
728 ) && detected.service == "monitoring"
729 {
730 let body_params = protocol::flatten_json_to_query(&body_bytes);
731 for (k, v) in body_params {
732 all_params.entry(k).or_insert(v);
733 }
734 }
735
736 let internal_caller = if is_fully_anonymous && access_key_id.is_none() {
742 parts.extensions.get::<InternalCaller>().cloned()
743 } else {
744 None
745 };
746 let caller_principal =
747 caller_principal.or_else(|| internal_caller.as_ref().map(|c| c.principal()));
748
749 let error_envelope = ErrorEnvelope::for_request(&detected, &parts.headers);
750 let aws_request = AwsRequest {
751 service: detected.service.clone(),
752 action: detected.action.clone(),
753 region,
754 account_id: match internal_caller.as_ref() {
758 Some(caller) => {
759 internal_caller_account(caller, &detected.service, &path_segments, &config)
760 }
761 None => caller_principal
762 .as_ref()
763 .map(|p| p.account_id.clone())
764 .unwrap_or_else(|| config.account_id.clone()),
765 },
766 request_id: request_id.clone(),
767 headers: parts.headers,
768 query_params: all_params,
769 body: body_bytes,
770 body_stream: parking_lot::Mutex::new(body_stream),
771 path_segments,
772 raw_path: path,
773 raw_query,
774 method: parts.method,
775 is_query_protocol: matches!(
776 detected.protocol,
777 AwsProtocol::Query | AwsProtocol::Ec2Query
778 ),
779 access_key_id,
780 principal: caller_principal,
781 };
782
783 tracing::info!(
784 service = %aws_request.service,
785 action = %aws_request.action,
786 request_id = %aws_request.request_id,
787 "handling request"
788 );
789
790 if config.iam_mode.is_enabled()
797 && service.iam_enforceable()
798 && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
799 {
800 if let Some(evaluator) = config.policy_evaluator.as_ref() {
801 if let Some(caller) = internal_caller.as_ref() {
802 if let Some(denied) = authorize_internal_caller(
803 caller,
804 service.as_ref(),
805 &aws_request,
806 evaluator.as_ref(),
807 &config,
808 &detected,
809 &request_id,
810 ) {
811 return denied;
812 }
813 } else if let Some(principal) = aws_request.principal.as_ref() {
814 if principal.is_root() {
815 if let Some(denied) = authorize_root_cross_account(
816 principal,
817 service.as_ref(),
818 &aws_request,
819 evaluator.as_ref(),
820 &config,
821 &detected,
822 &request_id,
823 remote_addr,
824 resolved.as_ref(),
825 ) {
826 return denied;
827 }
828 } else {
829 let iam_actions = service.iam_actions_for(&aws_request);
832 if !iam_actions.is_empty() {
833 for iam_action in &iam_actions {
834 let mut condition_context = principal_condition_context(
835 principal,
836 resolved.as_ref(),
837 service.as_ref(),
838 &aws_request,
839 iam_action,
840 &detected,
841 remote_addr,
842 );
843 let service_resource = !iam_action.is_pass_role();
844
845 let in_caller_account =
857 service.iam_resource_in_caller_account(&aws_request);
858 let resource_policy_json = config
859 .resource_policy_provider
860 .as_ref()
861 .filter(|_| service_resource && !in_caller_account)
862 .and_then(|p| {
863 p.resource_policy(&detected.service, &iam_action.resource)
864 });
865 let resource_account_id = config
875 .resource_policy_provider
876 .as_ref()
877 .filter(|_| service_resource && !in_caller_account)
878 .and_then(|p| {
879 p.resource_owner_account(
880 &detected.service,
881 &iam_action.resource,
882 )
883 })
884 .or_else(|| parse_account_from_arn(&iam_action.resource))
885 .unwrap_or_else(|| principal.account_id.clone());
886 let scps = config
893 .scp_resolver
894 .as_ref()
895 .and_then(|r| r.scps_for(principal));
896 add_global_request_keys(
902 &mut condition_context,
903 principal,
904 &resource_account_id,
905 config.scp_resolver.as_deref(),
906 );
907 let decision = evaluator.evaluate_with_resource_policy(
908 principal,
909 iam_action,
910 &condition_context,
911 resource_policy_json.as_deref(),
912 &resource_account_id,
913 &caller_session_policies,
914 scps.as_deref(),
915 );
916 if !decision.is_allow() {
917 tracing::warn!(
918 target: "fakecloud::iam::audit",
919 service = %detected.service,
920 action = %iam_action.action_string(),
921 resource = %iam_action.resource,
922 principal = %principal.arn,
923 resource_policy_present = resource_policy_json.is_some(),
924 decision = ?decision,
925 mode = %config.iam_mode,
926 request_id = %request_id,
927 "IAM policy evaluation denied request"
928 );
929 if config.iam_mode.is_strict() {
930 let context_summary = serde_json::json!({
943 "aws:PrincipalArn": principal.arn,
944 "aws:PrincipalAccount": principal.account_id,
945 "aws:RequestedRegion": condition_context
946 .aws_requested_region
947 .clone()
948 .unwrap_or_default(),
949 "aws:SecureTransport": condition_context
950 .aws_secure_transport
951 .unwrap_or(false),
952 "aws:Action": iam_action.action_string(),
953 "aws:Resource": iam_action.resource,
954 "decision": format!("{:?}", decision),
955 });
956 let action_string = iam_action.action_string();
957 let encoded = crate::auth_message::encode_deny(
958 matches!(decision, crate::auth::IamDecision::ExplicitDeny),
959 Some(&action_string),
960 Some(&principal.arn),
961 Vec::new(),
962 Some(context_summary),
963 );
964 return build_error_response(
965 StatusCode::FORBIDDEN,
966 "AccessDeniedException",
967 &format!(
968 "User: {} is not authorized to perform: {} on resource: {} Encoded authorization failure message: {}",
969 principal.arn,
970 iam_action.action_string(),
971 iam_action.resource,
972 encoded,
973 ),
974 &request_id,
975 error_envelope,
976 );
977 }
978 }
981 }
982 } else {
983 tracing::warn!(
995 target: "fakecloud::iam::audit",
996 service = %detected.service,
997 action = %aws_request.action,
998 mode = %config.iam_mode,
999 request_id = %request_id,
1000 "service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
1001 );
1002 if config.iam_mode.is_strict() {
1003 return build_error_response(
1004 StatusCode::FORBIDDEN,
1005 "AccessDeniedException",
1006 &format!(
1007 "User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
1008 principal.arn, aws_request.action,
1009 ),
1010 &request_id,
1011 error_envelope,
1012 );
1013 }
1014 }
1017 }
1018 } else if let Some(akid) = aws_request
1019 .access_key_id
1020 .as_deref()
1021 .filter(|_| aws_request.principal.is_none())
1022 {
1023 tracing::warn!(
1034 target: "fakecloud::iam::audit",
1035 service = %detected.service,
1036 action = %aws_request.action,
1037 mode = %config.iam_mode,
1038 request_id = %request_id,
1039 "request credential does not resolve to an identity; denying under strict, allowing under soft"
1040 );
1041 if config.iam_mode.is_strict() {
1042 return unresolved_credential_response(
1043 &config,
1044 akid,
1045 &request_id,
1046 ErrorEnvelope::for_request(&detected, &aws_request.headers),
1047 );
1048 }
1049 } else if aws_request.access_key_id.is_none() {
1050 let iam_actions = service.iam_actions_for(&aws_request);
1062 if !iam_actions.is_empty() {
1063 for iam_action in &iam_actions {
1064 let now = chrono::Utc::now();
1065 let mut condition_context = ConditionContext {
1066 aws_source_ip: remote_addr.map(|sa| sa.ip()),
1067 aws_current_time: Some(now),
1068 aws_epoch_time: Some(now.timestamp()),
1069 aws_secure_transport: Some(is_secure_transport(&aws_request.headers)),
1070 aws_requested_region: Some(aws_request.region.clone()),
1071 ..Default::default()
1072 };
1073 condition_context.service_keys =
1074 service.iam_condition_keys_for(&aws_request, iam_action);
1075 if let Some(owner) = config
1078 .resource_policy_provider
1079 .as_ref()
1080 .and_then(|p| {
1081 p.resource_owner_account(&detected.service, &iam_action.resource)
1082 })
1083 .or_else(|| parse_account_from_arn(&iam_action.resource))
1084 {
1085 condition_context
1086 .service_keys
1087 .entry("aws:resourceaccount".to_string())
1088 .or_insert_with(|| vec![owner]);
1089 }
1090 let resource_policy_json =
1091 config.resource_policy_provider.as_ref().and_then(|p| {
1092 p.resource_policy(&detected.service, &iam_action.resource)
1093 });
1094 let policy_decision = evaluator.evaluate_anonymous(
1095 iam_action,
1096 &condition_context,
1097 resource_policy_json.as_deref(),
1098 );
1099 let policy_allows = policy_decision.is_allow();
1100 let policy_explicit_deny =
1105 matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny);
1106 let acl_allows = !policy_explicit_deny
1107 && config.resource_policy_provider.as_ref().is_some_and(|p| {
1108 p.public_acl_allows(
1109 &detected.service,
1110 &iam_action.resource,
1111 iam_action.action,
1112 )
1113 });
1114 if !policy_allows && !acl_allows {
1115 tracing::warn!(
1116 target: "fakecloud::iam::audit",
1117 service = %detected.service,
1118 action = %iam_action.action_string(),
1119 resource = %iam_action.resource,
1120 resource_policy_present = resource_policy_json.is_some(),
1121 mode = %config.iam_mode,
1122 request_id = %request_id,
1123 "anonymous request denied: no public bucket policy or ACL grants the action"
1124 );
1125 if config.iam_mode.is_strict() {
1126 return build_error_response(
1127 StatusCode::FORBIDDEN,
1128 "AccessDenied",
1129 "Access Denied",
1130 &request_id,
1131 error_envelope,
1132 );
1133 }
1134 }
1136 }
1137 } else {
1138 tracing::warn!(
1145 target: "fakecloud::iam::audit",
1146 service = %detected.service,
1147 action = %aws_request.action,
1148 mode = %config.iam_mode,
1149 request_id = %request_id,
1150 "anonymous request to iam_enforceable service has no IamAction mapping; denying under strict, allowing under soft"
1151 );
1152 if config.iam_mode.is_strict() {
1153 return build_error_response(
1154 StatusCode::FORBIDDEN,
1155 "AccessDenied",
1156 "Access Denied",
1157 &request_id,
1158 error_envelope,
1159 );
1160 }
1161 }
1162 }
1163 }
1164 }
1165
1166 match service.handle(aws_request).await {
1167 Ok(resp) => {
1168 let resp = if detected.protocol == AwsProtocol::RpcV2Cbor {
1169 rpc_v2_cbor_response(resp)
1170 } else {
1171 resp
1172 };
1173 let mut builder = Response::builder()
1174 .status(resp.status)
1175 .header("x-amzn-requestid", &request_id)
1176 .header("x-amz-request-id", &request_id);
1177
1178 if !resp.content_type.is_empty() {
1179 builder = builder.header("content-type", &resp.content_type);
1180 }
1181
1182 let has_content_length = resp
1183 .headers
1184 .iter()
1185 .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
1186
1187 for (k, v) in &resp.headers {
1188 builder = builder.header(k, v);
1189 }
1190
1191 match resp.body {
1192 ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
1193 ResponseBody::File { file, size } => {
1194 let stream = tokio_util::io::ReaderStream::new(file);
1195 let body = Body::from_stream(stream);
1196 if !has_content_length {
1197 builder = builder.header("content-length", size.to_string());
1198 }
1199 builder.body(body).unwrap()
1200 }
1201 }
1202 }
1203 Err(err) => {
1204 tracing::warn!(
1205 service = %detected.service,
1206 action = %detected.action,
1207 error = %err,
1208 "request failed"
1209 );
1210 let error_headers = err.response_headers().to_vec();
1211 let mut resp = build_error_response_with_fields(
1212 err.status(),
1213 err.code(),
1214 &err.message(),
1215 &request_id,
1216 error_envelope,
1217 err.extra_fields(),
1218 );
1219 for (k, v) in &error_headers {
1220 if let (Ok(name), Ok(val)) = (
1221 k.parse::<http::header::HeaderName>(),
1222 v.parse::<http::header::HeaderValue>(),
1223 ) {
1224 if name == http::header::VARY {
1230 let already = resp
1231 .headers()
1232 .get_all(&name)
1233 .iter()
1234 .any(|existing| existing == val);
1235 if !already {
1236 resp.headers_mut().append(name, val);
1237 }
1238 } else {
1239 resp.headers_mut().insert(name, val);
1240 }
1241 }
1242 }
1243 resp
1244 }
1245 }
1246}
1247
1248fn rpc_v2_cbor_response(mut resp: crate::service::AwsResponse) -> crate::service::AwsResponse {
1253 if resp.content_type != crate::cbor::CBOR_CONTENT_TYPE {
1254 if let ResponseBody::Bytes(bytes) = &resp.body {
1255 let json = if bytes.is_empty() {
1256 serde_json::Value::Object(serde_json::Map::new())
1257 } else {
1258 serde_json::from_slice(bytes).unwrap_or_else(|e| {
1259 tracing::warn!(error = %e, "non-JSON response body for an rpcv2Cbor request");
1260 serde_json::Value::Object(serde_json::Map::new())
1261 })
1262 };
1263 resp.body = ResponseBody::Bytes(Bytes::from(crate::cbor::encode(
1264 &crate::cbor::json_to_cbor(&json),
1265 )));
1266 resp.content_type = crate::cbor::CBOR_CONTENT_TYPE.to_string();
1267 }
1268 }
1269 resp.headers.insert(
1270 http::HeaderName::from_static(crate::cbor::SMITHY_PROTOCOL_HEADER),
1271 http::HeaderValue::from_static(crate::cbor::RPC_V2_CBOR),
1272 );
1273 resp
1274}
1275
1276#[derive(Clone)]
1278pub struct DispatchConfig {
1279 pub region: String,
1280 pub account_id: String,
1281 pub verify_sigv4: bool,
1285 pub iam_mode: IamMode,
1290 pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
1294 pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
1298 pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
1305 pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
1312}
1313
1314impl std::fmt::Debug for DispatchConfig {
1315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1316 f.debug_struct("DispatchConfig")
1317 .field("region", &self.region)
1318 .field("account_id", &self.account_id)
1319 .field("verify_sigv4", &self.verify_sigv4)
1320 .field("iam_mode", &self.iam_mode)
1321 .field(
1322 "credential_resolver",
1323 &self
1324 .credential_resolver
1325 .as_ref()
1326 .map(|_| "<CredentialResolver>"),
1327 )
1328 .field(
1329 "policy_evaluator",
1330 &self
1331 .policy_evaluator
1332 .as_ref()
1333 .map(|_| "<IamPolicyEvaluator>"),
1334 )
1335 .field(
1336 "resource_policy_provider",
1337 &self
1338 .resource_policy_provider
1339 .as_ref()
1340 .map(|_| "<ResourcePolicyProvider>"),
1341 )
1342 .field(
1343 "scp_resolver",
1344 &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
1345 )
1346 .finish()
1347 }
1348}
1349
1350impl DispatchConfig {
1351 pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
1354 Self {
1355 region: region.into(),
1356 account_id: account_id.into(),
1357 verify_sigv4: false,
1358 iam_mode: IamMode::Off,
1359 credential_resolver: None,
1360 policy_evaluator: None,
1361 resource_policy_provider: None,
1362 scp_resolver: None,
1363 }
1364 }
1365}
1366
1367fn s3_routing_path(wire_path: &str, host_bucket: Option<&str>) -> String {
1383 let Some(bucket) = host_bucket else {
1384 return wire_path.to_string();
1385 };
1386 if wire_path == "/" || wire_path.is_empty() {
1387 format!("/{bucket}")
1388 } else {
1389 format!("/{bucket}{wire_path}")
1390 }
1391}
1392
1393fn streaming_route(
1413 method: &http::Method,
1414 path: &str,
1415 headers: &http::HeaderMap,
1416 query_params: &HashMap<String, String>,
1417) -> Option<(&'static str, &'static str)> {
1418 if (method == http::Method::PATCH || method == http::Method::PUT)
1420 && path.starts_with("/v2/")
1421 && path.contains("/blobs/uploads/")
1422 {
1423 return Some(("ecr", ""));
1424 }
1425
1426 if method == http::Method::PUT {
1431 let host_bucket = protocol::parse_routing_host_from_headers(headers)
1443 .filter(|h| h.service == "s3")
1444 .and_then(|h| h.bucket);
1445 let routed = s3_routing_path(path, host_bucket.as_deref());
1446 let has_key = routed.split('/').filter(|seg| !seg.is_empty()).count() >= 2;
1447 if !has_key {
1448 return None;
1449 }
1450 let header_s3 = headers
1451 .get("authorization")
1452 .and_then(|v| v.to_str().ok())
1453 .and_then(fakecloud_aws::sigv4::parse_sigv4)
1454 .map(|info| info.service == "s3")
1455 .unwrap_or(false);
1456 let presigned_v4_s3 = query_params
1457 .get("X-Amz-Credential")
1458 .and_then(|c| c.split('/').nth(3).map(|s| s.to_string()))
1459 .map(|service| service == "s3")
1460 .unwrap_or(false);
1461 let presigned_v2 = query_params.contains_key("AWSAccessKeyId")
1462 && query_params.contains_key("Signature")
1463 && query_params.contains_key("Expires");
1464 if header_s3 || presigned_v4_s3 || presigned_v2 {
1465 return Some(("s3", ""));
1466 }
1467 }
1468
1469 None
1470}
1471
1472const PRESIGN_AUTH_PARAMS: &[&str] = &[
1475 "x-amz-algorithm",
1476 "x-amz-credential",
1477 "x-amz-date",
1478 "x-amz-expires",
1479 "x-amz-signedheaders",
1480 "x-amz-signature",
1481 "x-amz-security-token",
1482];
1483
1484fn hoist_presigned_query_headers(
1490 headers: &mut http::HeaderMap,
1491 query_params: &HashMap<String, String>,
1492) {
1493 for (key, value) in query_params {
1494 let lower = key.to_ascii_lowercase();
1495 if !lower.starts_with("x-amz-") || PRESIGN_AUTH_PARAMS.contains(&lower.as_str()) {
1496 continue;
1497 }
1498 let Ok(name) = http::HeaderName::from_bytes(lower.as_bytes()) else {
1499 continue;
1500 };
1501 if headers.contains_key(&name) {
1502 continue;
1503 }
1504 let value = if lower.starts_with("x-amz-meta-") {
1505 crate::rfc2047::encode(value)
1506 } else {
1507 value.clone()
1508 };
1509 if let Ok(value) = http::HeaderValue::from_str(&value) {
1510 headers.insert(name, value);
1511 }
1512 }
1513}
1514
1515const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024 * 1024;
1525
1526pub fn max_request_body_bytes() -> usize {
1531 static CACHED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1532 *CACHED.get_or_init(|| {
1533 std::env::var("FAKECLOUD_MAX_REQUEST_BODY_BYTES")
1534 .ok()
1535 .and_then(|s| s.parse::<usize>().ok())
1536 .filter(|&n| n > 0)
1537 .unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES)
1538 })
1539}
1540
1541fn parse_account_from_arn(arn: &str) -> Option<String> {
1547 let mut parts = arn.splitn(6, ':');
1548 if parts.next()? != "arn" {
1549 return None;
1550 }
1551 let _partition = parts.next()?;
1552 let _service = parts.next()?;
1553 let _region = parts.next()?;
1554 let account = parts.next()?;
1555 parts.next()?;
1558 if account.is_empty() {
1559 None
1560 } else {
1561 Some(account.to_string())
1562 }
1563}
1564
1565fn user_agent_indicates_neptune(headers: &http::HeaderMap) -> bool {
1571 for name in ["user-agent", "x-amz-user-agent"] {
1572 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1573 for part in ua.split_whitespace() {
1574 if let Some(rest) = part.strip_prefix("api/neptune") {
1575 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1576 return true;
1577 }
1578 }
1579 }
1580 }
1581 }
1582 false
1583}
1584
1585fn user_agent_indicates_docdb(headers: &http::HeaderMap) -> bool {
1592 for name in ["user-agent", "x-amz-user-agent"] {
1593 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1594 for part in ua.split_whitespace() {
1595 if let Some(rest) = part.strip_prefix("api/docdb") {
1596 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1597 return true;
1598 }
1599 }
1600 }
1601 }
1602 }
1603 false
1604}
1605
1606fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
1607 let ua = headers.get("user-agent")?.to_str().ok()?;
1608 for part in ua.split_whitespace() {
1609 if let Some(region) = part.strip_prefix("region/") {
1610 if !region.is_empty() {
1611 return Some(region.to_string());
1612 }
1613 }
1614 }
1615 None
1616}
1617
1618#[derive(Clone, Copy, Debug)]
1623struct ErrorEnvelope {
1624 protocol: AwsProtocol,
1625 rest_xml_namespace: Option<&'static str>,
1627 s3_control: bool,
1630}
1631
1632impl ErrorEnvelope {
1633 fn for_request(detected: &protocol::DetectedRequest, headers: &http::HeaderMap) -> Self {
1635 let rest_xml_namespace = if detected.protocol == AwsProtocol::Rest {
1636 fakecloud_aws::error::rest_xml_error_namespace(&detected.service)
1637 } else {
1638 None
1639 };
1640 Self {
1641 protocol: detected.protocol,
1642 rest_xml_namespace,
1643 s3_control: detected.protocol == AwsProtocol::Rest
1644 && detected.service == "s3"
1645 && protocol::is_s3_control_host(headers),
1646 }
1647 }
1648}
1649
1650impl From<AwsProtocol> for ErrorEnvelope {
1651 fn from(protocol: AwsProtocol) -> Self {
1652 Self {
1653 protocol,
1654 rest_xml_namespace: None,
1655 s3_control: false,
1656 }
1657 }
1658}
1659
1660fn build_error_response(
1661 status: StatusCode,
1662 code: &str,
1663 message: &str,
1664 request_id: &str,
1665 envelope: impl Into<ErrorEnvelope>,
1666) -> Response<Body> {
1667 build_error_response_with_fields(status, code, message, request_id, envelope, &[])
1668}
1669
1670fn build_error_response_with_fields(
1671 status: StatusCode,
1672 code: &str,
1673 message: &str,
1674 request_id: &str,
1675 envelope: impl Into<ErrorEnvelope>,
1676 extra_fields: &[(String, String)],
1677) -> Response<Body> {
1678 let envelope = envelope.into();
1679 let (status, content_type, body) = match (envelope.protocol, envelope.rest_xml_namespace) {
1680 (AwsProtocol::Query, _) => {
1683 fakecloud_aws::error::xml_error_response(status, code, message, request_id)
1684 }
1685 (AwsProtocol::Ec2Query, _) => {
1690 fakecloud_aws::ec2query::ec2_error_response(status, code, message, request_id)
1691 }
1692 (AwsProtocol::Rest, Some(namespace)) => fakecloud_aws::error::rest_xml_error_response(
1695 status, code, message, request_id, namespace,
1696 ),
1697 (AwsProtocol::Rest, None) if envelope.s3_control => {
1698 fakecloud_aws::error::s3_control_xml_error_response(
1699 status,
1700 code,
1701 message,
1702 request_id,
1703 extra_fields,
1704 )
1705 }
1706 (AwsProtocol::Rest, None) => fakecloud_aws::error::s3_xml_error_response_with_fields(
1707 status,
1708 code,
1709 message,
1710 request_id,
1711 extra_fields,
1712 ),
1713 (AwsProtocol::Json | AwsProtocol::RestJson, _) => {
1714 fakecloud_aws::error::json_error_response_with_fields(
1715 status,
1716 code,
1717 message,
1718 extra_fields,
1719 )
1720 }
1721 (AwsProtocol::RpcV2Cbor, _) => (
1722 status,
1723 crate::cbor::CBOR_CONTENT_TYPE.to_string(),
1724 Bytes::from(crate::cbor::error_body(code, message, extra_fields)),
1725 ),
1726 };
1727
1728 let safe_code = sanitize_header_value(code);
1738 let safe_message = sanitize_header_value(message);
1739 let mut builder = Response::builder()
1740 .status(status)
1741 .header("content-type", content_type)
1742 .header("x-amzn-requestid", request_id)
1743 .header("x-amz-request-id", request_id);
1744 if let Ok(v) = http::HeaderValue::from_str(&safe_code) {
1745 builder = builder.header("x-amz-error-code", v);
1746 }
1747 if let Ok(v) = http::HeaderValue::from_str(&safe_message) {
1748 builder = builder.header("x-amz-error-message", v);
1749 }
1750 if envelope.protocol == AwsProtocol::RpcV2Cbor {
1751 builder = builder.header(
1752 crate::cbor::SMITHY_PROTOCOL_HEADER,
1753 crate::cbor::RPC_V2_CBOR,
1754 );
1755 }
1756 builder.body(Body::from(body)).unwrap_or_else(|_| {
1757 Response::new(Body::empty())
1761 })
1762}
1763
1764fn sanitize_header_value(s: &str) -> String {
1769 const MAX_LEN: usize = 1024;
1770 let mut out = String::with_capacity(s.len().min(MAX_LEN));
1771 for ch in s.chars() {
1772 if out.len() >= MAX_LEN {
1773 break;
1774 }
1775 if ch.is_control() {
1778 if !out.ends_with(' ') {
1779 out.push(' ');
1780 }
1781 } else {
1782 out.push(ch);
1783 }
1784 }
1785 out.trim().to_string()
1786}
1787
1788fn sigv2_presigned_access_key(query_params: &HashMap<String, String>) -> Option<String> {
1808 if query_params.contains_key("Signature") && query_params.contains_key("Expires") {
1809 query_params.get("AWSAccessKeyId").cloned()
1810 } else {
1811 None
1812 }
1813}
1814
1815fn is_hex_sha256(s: &str) -> bool {
1821 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
1822}
1823
1824fn sha256_hex_lower(bytes: &[u8]) -> String {
1827 use sha2::{Digest, Sha256};
1828 let digest = Sha256::digest(bytes);
1829 const HEX: &[u8] = b"0123456789abcdef";
1830 let mut out = String::with_capacity(64);
1831 for b in digest {
1832 out.push(HEX[(b >> 4) as usize] as char);
1833 out.push(HEX[(b & 0x0f) as usize] as char);
1834 }
1835 out
1836}
1837
1838#[allow(clippy::too_many_arguments)]
1844fn principal_condition_context(
1845 principal: &Principal,
1846 resolved: Option<&crate::auth::ResolvedCredential>,
1847 service: &dyn crate::service::AwsService,
1848 aws_request: &AwsRequest,
1849 iam_action: &crate::auth::IamAction,
1850 detected: &protocol::DetectedRequest,
1851 remote_addr: Option<SocketAddr>,
1852) -> ConditionContext {
1853 let mut ctx = build_condition_context(
1854 principal,
1855 remote_addr,
1856 &aws_request.region,
1857 is_secure_transport(&aws_request.headers),
1858 );
1859 if let Some(rc) = resolved {
1867 ctx.aws_mfa_present = Some(rc.mfa_present);
1868 ctx.aws_token_issue_time = rc.token_issued_at;
1869 ctx.aws_federated_provider = rc.federated_provider.clone();
1870 if rc.mfa_present {
1878 if let Some(issued) = rc.token_issued_at {
1879 let age = chrono::Utc::now()
1880 .signed_duration_since(issued)
1881 .num_seconds()
1882 .max(0);
1883 ctx.aws_mfa_age_seconds = Some(age);
1884 }
1885 }
1886 }
1887 ctx.service_keys = service.iam_condition_keys_for(aws_request, iam_action);
1888
1889 let service_resource = !iam_action.is_pass_role();
1895 match service_resource
1896 .then(|| service.resource_tags_for(&iam_action.resource))
1897 .flatten()
1898 {
1899 Some(tags) => ctx.resource_tags = Some(tags),
1900 None => tracing::debug!(
1901 target: "fakecloud::iam::audit",
1902 service = %detected.service,
1903 resource = %iam_action.resource,
1904 "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
1905 ),
1906 }
1907 match service.request_tags_from(aws_request, iam_action.action) {
1909 Some(tags) => ctx.request_tags = Some(tags),
1910 None => tracing::debug!(
1911 target: "fakecloud::iam::audit",
1912 service = %detected.service,
1913 action = %iam_action.action_string(),
1914 "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
1915 ),
1916 }
1917 ctx.principal_tags = principal.tags.clone();
1919 ctx
1920}
1921
1922fn add_global_request_keys(
1927 ctx: &mut ConditionContext,
1928 principal: &Principal,
1929 resource_account_id: &str,
1930 scp_resolver: Option<&dyn crate::auth::ScpResolver>,
1931) {
1932 if !resource_account_id.is_empty() {
1933 ctx.service_keys
1934 .entry("aws:resourceaccount".to_string())
1935 .or_insert_with(|| vec![resource_account_id.to_string()]);
1936 }
1937 if let Some((org_id, path)) = scp_resolver.and_then(|r| r.principal_org(&principal.account_id))
1938 {
1939 ctx.service_keys
1940 .entry("aws:principalorgid".to_string())
1941 .or_insert_with(|| vec![org_id]);
1942 ctx.service_keys
1943 .entry("aws:principalorgpaths".to_string())
1944 .or_insert_with(|| vec![path]);
1945 }
1946}
1947
1948fn unresolved_credential_response(
1952 config: &DispatchConfig,
1953 access_key_id: &str,
1954 request_id: &str,
1955 envelope: ErrorEnvelope,
1956) -> Response<Body> {
1957 let expired = config
1958 .credential_resolver
1959 .as_ref()
1960 .is_some_and(|r| r.is_expired(access_key_id));
1961 let (code, message) = if expired {
1962 (
1963 "ExpiredToken",
1964 "The security token included in the request is expired",
1965 )
1966 } else {
1967 (
1968 "InvalidClientTokenId",
1969 "The security token included in the request is invalid",
1970 )
1971 };
1972 build_error_response(StatusCode::FORBIDDEN, code, message, request_id, envelope)
1973}
1974
1975fn anonymous_s3_bucket(uri: &http::Uri, config: &DispatchConfig) -> Option<String> {
1976 let provider = config.resource_policy_provider.as_ref()?;
1977 let segment = uri.path().split('/').find(|s| !s.is_empty())?.to_string();
1978 let arn = fakecloud_aws::arn::Arn::s3(&segment).to_string();
1979 provider.resource_owner_account("s3", &arn).map(|_| segment)
1980}
1981
1982fn internal_caller_account(
1987 caller: &InternalCaller,
1988 service: &str,
1989 path_segments: &[String],
1990 config: &DispatchConfig,
1991) -> String {
1992 let bucket_owner = (service == "s3")
1993 .then(|| path_segments.first())
1994 .flatten()
1995 .and_then(|bucket| {
1996 let arn = fakecloud_aws::arn::Arn::s3(bucket).to_string();
1997 config
1998 .resource_policy_provider
1999 .as_ref()?
2000 .resource_owner_account("s3", &arn)
2001 });
2002 bucket_owner.unwrap_or_else(|| caller.acting_account().to_string())
2003}
2004
2005fn authorize_internal_caller(
2017 caller: &InternalCaller,
2018 service: &dyn crate::service::AwsService,
2019 aws_request: &AwsRequest,
2020 evaluator: &dyn IamPolicyEvaluator,
2021 config: &DispatchConfig,
2022 detected: &protocol::DetectedRequest,
2023 request_id: &str,
2024) -> Option<Response<Body>> {
2025 let principal = caller.principal();
2026 let denied = || {
2027 config.iam_mode.is_strict().then(|| {
2028 build_error_response(
2029 StatusCode::FORBIDDEN,
2030 "AccessDenied",
2031 "Access Denied",
2032 request_id,
2033 ErrorEnvelope::for_request(detected, &aws_request.headers),
2034 )
2035 })
2036 };
2037 let iam_actions = service.iam_actions_for(aws_request);
2038 if iam_actions.is_empty() {
2039 tracing::warn!(
2040 target: "fakecloud::iam::audit",
2041 service = %detected.service,
2042 action = %aws_request.action,
2043 principal = %principal.arn,
2044 mode = %config.iam_mode,
2045 request_id = %request_id,
2046 "service-principal request has no IamAction mapping; denying under strict, allowing under soft"
2047 );
2048 return denied();
2049 }
2050 for iam_action in iam_actions.iter().filter(|a| !a.is_pass_role()) {
2053 let now = chrono::Utc::now();
2054 let mut context = ConditionContext {
2055 aws_principal_arn: Some(principal.arn.clone()),
2056 aws_current_time: Some(now),
2057 aws_epoch_time: Some(now.timestamp()),
2058 aws_secure_transport: Some(is_secure_transport(&aws_request.headers)),
2059 aws_requested_region: Some(aws_request.region.clone()),
2060 ..Default::default()
2061 };
2062 context.service_keys = service.iam_condition_keys_for(aws_request, iam_action);
2063 context.service_keys.extend(caller.condition_keys());
2064 context
2067 .service_keys
2068 .entry("aws:resourceaccount".to_string())
2069 .or_insert_with(|| vec![aws_request.account_id.clone()]);
2070 let resource_policy_json = config
2071 .resource_policy_provider
2072 .as_ref()
2073 .and_then(|p| p.resource_policy(&detected.service, &iam_action.resource));
2074 let decision = evaluator.evaluate_resource_policy_only(
2075 &principal,
2076 iam_action,
2077 &context,
2078 resource_policy_json.as_deref(),
2079 );
2080 let explicit_deny = matches!(decision, crate::auth::IamDecision::ExplicitDeny);
2081 let acl_allows = !explicit_deny
2082 && config.resource_policy_provider.as_ref().is_some_and(|p| {
2083 p.public_acl_allows(&detected.service, &iam_action.resource, iam_action.action)
2084 });
2085 if !decision.is_allow() && !acl_allows {
2086 tracing::warn!(
2087 target: "fakecloud::iam::audit",
2088 service = %detected.service,
2089 action = %iam_action.action_string(),
2090 resource = %iam_action.resource,
2091 principal = %principal.arn,
2092 resource_policy_present = resource_policy_json.is_some(),
2093 decision = ?decision,
2094 mode = %config.iam_mode,
2095 request_id = %request_id,
2096 "service-principal request denied: the resource policy does not grant the action"
2097 );
2098 if let Some(resp) = denied() {
2099 return Some(resp);
2100 }
2101 }
2102 }
2103 None
2104}
2105
2106#[allow(clippy::too_many_arguments)]
2122fn authorize_root_cross_account(
2123 principal: &Principal,
2124 service: &dyn crate::service::AwsService,
2125 aws_request: &AwsRequest,
2126 evaluator: &dyn IamPolicyEvaluator,
2127 config: &DispatchConfig,
2128 detected: &protocol::DetectedRequest,
2129 request_id: &str,
2130 remote_addr: Option<SocketAddr>,
2131 resolved: Option<&crate::auth::ResolvedCredential>,
2132) -> Option<Response<Body>> {
2133 if service.iam_resource_in_caller_account(aws_request) {
2134 return None;
2135 }
2136 let provider = config.resource_policy_provider.as_ref()?;
2137 for iam_action in service.iam_actions_for(aws_request) {
2138 let Some(owner) = provider.resource_owner_account(&detected.service, &iam_action.resource)
2139 else {
2140 continue;
2141 };
2142 if owner == principal.account_id {
2143 continue;
2144 }
2145 let mut context = principal_condition_context(
2146 principal,
2147 resolved,
2148 service,
2149 aws_request,
2150 &iam_action,
2151 detected,
2152 remote_addr,
2153 );
2154 add_global_request_keys(
2155 &mut context,
2156 principal,
2157 &owner,
2158 config.scp_resolver.as_deref(),
2159 );
2160 let resource_policy_json =
2161 provider.resource_policy(&detected.service, &iam_action.resource);
2162 let decision = evaluator.evaluate_resource_policy_only(
2163 principal,
2164 &iam_action,
2165 &context,
2166 resource_policy_json.as_deref(),
2167 );
2168 let explicit_deny = matches!(decision, crate::auth::IamDecision::ExplicitDeny);
2169 let acl_allows = !explicit_deny
2170 && provider.public_acl_allows(
2171 &detected.service,
2172 &iam_action.resource,
2173 iam_action.action,
2174 );
2175 if decision.is_allow() || acl_allows {
2176 continue;
2177 }
2178 tracing::warn!(
2179 target: "fakecloud::iam::audit",
2180 service = %detected.service,
2181 action = %iam_action.action_string(),
2182 resource = %iam_action.resource,
2183 principal = %principal.arn,
2184 resource_account = %owner,
2185 resource_policy_present = resource_policy_json.is_some(),
2186 decision = ?decision,
2187 mode = %config.iam_mode,
2188 request_id = %request_id,
2189 "cross-account root request denied: the resource policy does not grant the action"
2190 );
2191 if config.iam_mode.is_strict() {
2192 return Some(build_error_response(
2193 StatusCode::FORBIDDEN,
2194 "AccessDeniedException",
2195 &format!(
2196 "User: {} is not authorized to perform: {} on resource: {} because no resource-based policy allows the {} action",
2197 principal.arn,
2198 iam_action.action_string(),
2199 iam_action.resource,
2200 iam_action.action_string(),
2201 ),
2202 request_id,
2203 ErrorEnvelope::for_request(detected, &aws_request.headers),
2204 ));
2205 }
2206 }
2207 None
2208}
2209
2210fn build_condition_context(
2211 principal: &Principal,
2212 remote_addr: Option<SocketAddr>,
2213 region: &str,
2214 secure_transport: bool,
2215) -> ConditionContext {
2216 let now = chrono::Utc::now();
2217 ConditionContext {
2218 aws_username: aws_username_from_principal(principal),
2219 aws_userid: Some(principal.user_id.clone()),
2220 aws_principal_arn: Some(principal.arn.clone()),
2221 aws_principal_account: Some(principal.account_id.clone()),
2222 aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
2223 aws_source_ip: remote_addr.map(|sa| sa.ip()),
2224 aws_current_time: Some(now),
2225 aws_epoch_time: Some(now.timestamp()),
2226 aws_secure_transport: Some(secure_transport),
2227 aws_requested_region: Some(region.to_string()),
2228 aws_mfa_present: None,
2234 aws_mfa_age_seconds: None,
2235 aws_called_via: Vec::new(),
2236 aws_source_vpce: None,
2237 aws_source_vpc: None,
2238 aws_vpc_source_ip: None,
2239 aws_federated_provider: None,
2240 aws_token_issue_time: None,
2241 service_keys: Default::default(),
2242 resource_tags: None,
2243 request_tags: None,
2244 principal_tags: None,
2245 }
2246}
2247
2248fn aws_username_from_principal(principal: &Principal) -> Option<String> {
2252 if principal.principal_type != PrincipalType::User {
2253 return None;
2254 }
2255 let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
2256 Some(after.rsplit('/').next().unwrap_or(after).to_string())
2258}
2259
2260fn principal_type_label(t: PrincipalType) -> &'static str {
2263 match t {
2264 PrincipalType::User => "User",
2265 PrincipalType::AssumedRole => "AssumedRole",
2266 PrincipalType::FederatedUser => "FederatedUser",
2267 PrincipalType::Root => "Account",
2268 PrincipalType::Unknown => "Unknown",
2269 PrincipalType::Service => "Service",
2270 }
2271}
2272
2273fn is_secure_transport(headers: &http::HeaderMap) -> bool {
2279 headers
2280 .get("x-forwarded-proto")
2281 .and_then(|v| v.to_str().ok())
2282 .map(|s| s.eq_ignore_ascii_case("https"))
2283 .unwrap_or(false)
2284}
2285
2286trait ProtocolExt {
2287 fn error_status(&self) -> StatusCode;
2288}
2289
2290impl ProtocolExt for AwsProtocol {
2291 fn error_status(&self) -> StatusCode {
2292 StatusCode::BAD_REQUEST
2293 }
2294}
2295
2296fn names_bedrock_session(label: &str) -> bool {
2299 let decoded = label
2300 .to_ascii_lowercase()
2301 .replace("%3a", ":")
2302 .replace("%2f", "/");
2303 decoded.starts_with("arn:") && decoded.contains(":bedrock:") && decoded.contains(":session/")
2304}
2305
2306fn bedrock_agent_service_for(method: &http::Method, path: &str) -> Option<&'static str> {
2312 let first_seg = path.split('/').nth(1);
2313 if !matches!(
2314 first_seg,
2315 Some(
2316 "agents"
2317 | "knowledgebases"
2318 | "flows"
2319 | "prompts"
2320 | "tags"
2321 | "retrieveAndGenerate"
2322 | "retrieveAndGenerateStream"
2323 | "optimize-prompt"
2324 | "sessions"
2325 | "invocations"
2326 | "generate-query"
2327 | "rerank"
2328 )
2329 ) {
2330 return None;
2331 }
2332 let segs: Vec<&str> = path.split('/').collect();
2333 let is_runtime = matches!(
2334 segs.as_slice(),
2335 ["", "agents", _, "agentAliases", _, ..] | ["", "flows", _, "executions"] | ["", "flows", _, "aliases", _, "executions", ..] | ["", "knowledgebases", _, "retrieve"] | ["", "retrieveAndGenerate"]
2340 | ["", "retrieveAndGenerateStream"]
2341 | ["", "optimize-prompt"]
2342 | ["", "sessions", ..]
2343 | ["", "invocations", ..]
2344 | ["", "generate-query"]
2345 | ["", "rerank"]
2346 ) || (
2347 *method == http::Method::POST && matches!(segs.as_slice(), ["", "flows", _, "aliases", _])
2350 ) || (
2351 *method == http::Method::POST
2354 && matches!(segs.as_slice(), ["", "agents", id] if !id.is_empty())
2355 ) || path
2356 .strip_prefix("/tags/")
2360 .is_some_and(names_bedrock_session);
2361 Some(if is_runtime {
2362 "bedrock-agent-runtime"
2363 } else {
2364 "bedrock-agent"
2365 })
2366}
2367
2368#[cfg(test)]
2369mod tests {
2370
2371 fn gzip(data: &[u8]) -> Bytes {
2372 use std::io::Write;
2373 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2374 enc.write_all(data).unwrap();
2375 Bytes::from(enc.finish().unwrap())
2376 }
2377
2378 fn gzip_headers(extra: &[(&'static str, &str)]) -> http::HeaderMap {
2379 let mut h = http::HeaderMap::new();
2380 h.insert("content-encoding", "gzip".parse().unwrap());
2381 for (k, v) in extra {
2382 h.insert(*k, v.parse().unwrap());
2383 }
2384 h
2385 }
2386
2387 #[test]
2388 fn request_compression_decodes_gzip_for_cloudwatch() {
2389 let body = br#"{"Namespace":"App"}"#;
2390 let h = gzip_headers(&[(
2392 "x-amz-target",
2393 "GraniteServiceVersion20100801.PutMetricData",
2394 )]);
2395 assert_eq!(
2396 decode_request_compression(&h, None, gzip(body)).unwrap(),
2397 Bytes::from_static(body)
2398 );
2399 let h = gzip_headers(&[(
2401 "authorization",
2402 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/monitoring/aws4_request, SignedHeaders=host, Signature=0",
2403 )]);
2404 let form = b"Action=PutMetricData&Namespace=App";
2405 assert_eq!(
2406 decode_request_compression(&h, None, gzip(form)).unwrap(),
2407 Bytes::from_static(form)
2408 );
2409 let detected = protocol::DetectedRequest {
2411 service: "monitoring".to_string(),
2412 action: "PutMetricData".to_string(),
2413 protocol: AwsProtocol::RpcV2Cbor,
2414 };
2415 let h = gzip_headers(&[]);
2416 assert_eq!(
2417 decode_request_compression(&h, Some(&detected), gzip(&[0xa0])).unwrap(),
2418 Bytes::from_static(&[0xa0])
2419 );
2420 let err = decode_request_compression(&h, Some(&detected), Bytes::from_static(b"nope"))
2422 .unwrap_err();
2423 assert_eq!(err.1, AwsProtocol::RpcV2Cbor);
2424 }
2425
2426 #[test]
2427 fn request_compression_leaves_other_services_alone() {
2428 let h = gzip_headers(&[(
2430 "authorization",
2431 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=0",
2432 )]);
2433 let body = gzip(b"object bytes");
2434 assert_eq!(
2435 decode_request_compression(&h, None, body.clone()).unwrap(),
2436 body
2437 );
2438 let h = http::HeaderMap::new();
2440 assert_eq!(
2441 decode_request_compression(&h, None, Bytes::from_static(b"x")).unwrap(),
2442 Bytes::from_static(b"x")
2443 );
2444 }
2445
2446 #[test]
2447 fn request_compression_services_match_the_models() {
2448 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../aws-models");
2451 let mut with_trait: Vec<String> = std::fs::read_dir(&dir)
2452 .unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
2453 .filter_map(|e| e.ok())
2454 .filter(|e| {
2455 std::fs::read_to_string(e.path())
2456 .is_ok_and(|s| s.contains("\"smithy.api#requestCompression\""))
2457 })
2458 .map(|e| e.file_name().to_string_lossy().into_owned())
2459 .collect();
2460 with_trait.sort();
2461 assert_eq!(
2462 with_trait,
2463 vec!["cloudwatch.json".to_string()],
2464 "update REQUEST_COMPRESSION_SERVICES for new @requestCompression models"
2465 );
2466 assert_eq!(REQUEST_COMPRESSION_SERVICES, &["monitoring"]);
2467 }
2468 #[test]
2469 fn bedrock_agent_paths_split_between_runtime_and_control_plane() {
2470 use http::Method;
2471 let runtime = Some("bedrock-agent-runtime");
2472 let agent = Some("bedrock-agent");
2473 for (method, path, want) in [
2474 (Method::POST, "/flows/F/aliases/A", runtime),
2475 (Method::GET, "/flows/F/aliases/A", agent),
2476 (Method::PUT, "/flows/F/aliases/A", agent),
2477 (Method::DELETE, "/flows/F/aliases/A", agent),
2478 (Method::POST, "/flows/F/aliases/A/executions", runtime),
2479 (Method::GET, "/flows/F/aliases/A/executions/E", runtime),
2480 (
2481 Method::POST,
2482 "/flows/F/aliases/A/executions/E/stop",
2483 runtime,
2484 ),
2485 (
2486 Method::GET,
2487 "/flows/F/aliases/A/executions/E/events",
2488 runtime,
2489 ),
2490 (
2491 Method::GET,
2492 "/flows/F/aliases/A/executions/E/flowsnapshot",
2493 runtime,
2494 ),
2495 (Method::GET, "/flows/F/executions", runtime),
2496 (Method::GET, "/flows/F/aliases", agent),
2497 (Method::POST, "/flows/F/versions", agent),
2498 (Method::GET, "/flows/F", agent),
2499 (
2500 Method::POST,
2501 "/agents/X/agentAliases/Y/sessions/S/text",
2502 runtime,
2503 ),
2504 (Method::GET, "/agents/X", agent),
2505 (Method::POST, "/agents/session-1", runtime),
2506 (Method::POST, "/agents/X/", agent),
2507 (Method::GET, "/agents/X/", agent),
2508 (Method::PUT, "/agents/X/", agent),
2509 (
2510 Method::POST,
2511 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Asession%2F0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
2512 runtime,
2513 ),
2514 (
2515 Method::GET,
2516 "/tags/arn:aws:bedrock:us-east-1:123456789012:session/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
2517 runtime,
2518 ),
2519 (
2520 Method::DELETE,
2521 "/tags/arn%3aaws%3abedrock%3aus-east-1%3a123456789012%3asession%2fabc",
2522 runtime,
2523 ),
2524 (
2525 Method::POST,
2526 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aagent%2FAGENT12345",
2527 agent,
2528 ),
2529 (
2530 Method::GET,
2531 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aflow%2FFLOW123456",
2532 agent,
2533 ),
2534 (Method::POST, "/model/m/invoke", None),
2535 ] {
2536 assert_eq!(
2537 bedrock_agent_service_for(&method, path),
2538 want,
2539 "{method} {path}"
2540 );
2541 }
2542 }
2543
2544 use super::*;
2545
2546 #[test]
2547 fn default_max_request_body_bytes_is_one_gib() {
2548 assert_eq!(DEFAULT_MAX_REQUEST_BODY_BYTES, 1024 * 1024 * 1024);
2552 }
2553
2554 #[test]
2555 fn sigv2_presigned_access_key_extracted_with_signature_and_expires() {
2556 let mut q = HashMap::new();
2557 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
2558 q.insert("Signature".to_string(), "abc%2Bdef".to_string());
2559 q.insert("Expires".to_string(), "1700000000".to_string());
2560 assert_eq!(
2561 sigv2_presigned_access_key(&q).as_deref(),
2562 Some("AKIAEXAMPLE")
2563 );
2564 }
2565
2566 #[test]
2567 fn sigv2_presigned_access_key_none_without_signature_or_expires() {
2568 let mut q = HashMap::new();
2571 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
2572 assert_eq!(sigv2_presigned_access_key(&q), None);
2573
2574 q.insert("Expires".to_string(), "1700000000".to_string());
2575 assert_eq!(
2576 sigv2_presigned_access_key(&q),
2577 None,
2578 "missing Signature must not qualify"
2579 );
2580 }
2581
2582 #[test]
2583 fn sigv2_presigned_access_key_none_for_unsigned_request() {
2584 assert_eq!(sigv2_presigned_access_key(&HashMap::new()), None);
2585 }
2586
2587 #[test]
2588 fn is_hex_sha256_accepts_real_digest_rejects_markers() {
2589 assert!(is_hex_sha256(&sha256_hex_lower(b"hello")));
2591 assert!(is_hex_sha256(
2592 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2593 ));
2594 assert!(!is_hex_sha256("UNSIGNED-PAYLOAD"));
2596 assert!(!is_hex_sha256("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"));
2597 assert!(!is_hex_sha256("STREAMING-UNSIGNED-PAYLOAD-TRAILER"));
2598 assert!(!is_hex_sha256("abc123"));
2600 assert!(!is_hex_sha256(
2601 "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
2602 ));
2603 }
2604
2605 #[test]
2606 fn sha256_hex_lower_matches_known_vectors() {
2607 assert_eq!(
2609 sha256_hex_lower(b""),
2610 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2611 );
2612 assert_eq!(
2613 sha256_hex_lower(b"abc"),
2614 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
2615 );
2616 assert_eq!(sha256_hex_lower(b"abc").len(), 64);
2617 }
2618
2619 #[test]
2620 fn global_request_keys_cover_resource_account_and_org() {
2621 struct Org;
2622 impl crate::auth::ScpResolver for Org {
2623 fn scps_for(&self, _: &Principal) -> Option<Vec<String>> {
2624 None
2625 }
2626 fn principal_org(&self, account: &str) -> Option<(String, String)> {
2627 (account == "111111111111")
2628 .then(|| ("o-abc".to_string(), "o-abc/r-1/ou-2/".to_string()))
2629 }
2630 }
2631 let principal = |account: &str| Principal {
2632 arn: format!("arn:aws:iam::{account}:user/u"),
2633 user_id: "AIDAU".into(),
2634 account_id: account.into(),
2635 principal_type: PrincipalType::User,
2636 source_identity: None,
2637 tags: None,
2638 };
2639 let mut ctx = ConditionContext::default();
2640 add_global_request_keys(
2641 &mut ctx,
2642 &principal("111111111111"),
2643 "222222222222",
2644 Some(&Org),
2645 );
2646 assert_eq!(
2647 ctx.lookup("aws:ResourceAccount"),
2648 Some(vec!["222222222222".into()])
2649 );
2650 assert_eq!(ctx.lookup("aws:PrincipalOrgID"), Some(vec!["o-abc".into()]));
2651 assert_eq!(
2652 ctx.lookup("aws:PrincipalOrgPaths"),
2653 Some(vec!["o-abc/r-1/ou-2/".into()])
2654 );
2655 let mut ctx = ConditionContext::default();
2657 add_global_request_keys(
2658 &mut ctx,
2659 &principal("333333333333"),
2660 "333333333333",
2661 Some(&Org),
2662 );
2663 assert_eq!(ctx.lookup("aws:PrincipalOrgID"), None);
2664 assert_eq!(
2665 ctx.lookup("aws:ResourceAccount"),
2666 Some(vec!["333333333333".into()])
2667 );
2668 }
2669
2670 #[test]
2671 fn unresolved_credential_distinguishes_expired_from_invalid() {
2672 struct Expired;
2673 impl CredentialResolver for Expired {
2674 fn resolve(&self, _: &str) -> Option<crate::auth::ResolvedCredential> {
2675 None
2676 }
2677 fn is_expired(&self, akid: &str) -> bool {
2678 akid == "ASIAEXPIRED"
2679 }
2680 }
2681 let mut cfg = DispatchConfig::new("us-east-1", "123456789012");
2682 cfg.credential_resolver = Some(Arc::new(Expired));
2683 let detected = protocol::DetectedRequest {
2684 service: "sts".into(),
2685 action: "GetCallerIdentity".into(),
2686 protocol: AwsProtocol::Query,
2687 };
2688 let code = |akid: &str| {
2689 let resp = unresolved_credential_response(
2690 &cfg,
2691 akid,
2692 "r",
2693 ErrorEnvelope::for_request(&detected, &http::HeaderMap::new()),
2694 );
2695 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2696 resp.headers()["x-amz-error-code"]
2697 .to_str()
2698 .unwrap()
2699 .to_string()
2700 };
2701 assert_eq!(code("ASIAEXPIRED"), "ExpiredToken");
2702 assert_eq!(code("AKIAUNKNOWN"), "InvalidClientTokenId");
2703 }
2704
2705 #[test]
2706 fn dispatch_config_new_defaults_to_off() {
2707 let cfg = DispatchConfig::new("us-east-1", "123456789012");
2708 assert_eq!(cfg.region, "us-east-1");
2709 assert_eq!(cfg.account_id, "123456789012");
2710 assert!(!cfg.verify_sigv4);
2711 assert_eq!(cfg.iam_mode, IamMode::Off);
2712 }
2713
2714 #[test]
2715 fn aws_username_strips_iam_path_for_users() {
2716 let p = Principal {
2717 arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
2718 user_id: "AIDAALICE".into(),
2719 account_id: "123456789012".into(),
2720 principal_type: PrincipalType::User,
2721 source_identity: None,
2722 tags: None,
2723 };
2724 assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
2725 }
2726
2727 #[test]
2728 fn aws_username_unset_for_assumed_role() {
2729 let p = Principal {
2730 arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
2731 user_id: "AROAOPS:session".into(),
2732 account_id: "123456789012".into(),
2733 principal_type: PrincipalType::AssumedRole,
2734 source_identity: None,
2735 tags: None,
2736 };
2737 assert_eq!(aws_username_from_principal(&p), None);
2738 }
2739
2740 #[test]
2741 fn principal_type_label_matches_aws_casing() {
2742 assert_eq!(principal_type_label(PrincipalType::User), "User");
2743 assert_eq!(
2744 principal_type_label(PrincipalType::AssumedRole),
2745 "AssumedRole"
2746 );
2747 assert_eq!(principal_type_label(PrincipalType::Root), "Account");
2748 }
2749
2750 #[test]
2751 fn build_condition_context_populates_global_keys() {
2752 let p = Principal {
2753 arn: "arn:aws:iam::123456789012:user/alice".into(),
2754 user_id: "AIDAALICE".into(),
2755 account_id: "123456789012".into(),
2756 principal_type: PrincipalType::User,
2757 source_identity: None,
2758 tags: None,
2759 };
2760 let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
2761 let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
2762 assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
2763 assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
2764 assert_eq!(
2765 ctx.aws_principal_arn.as_deref(),
2766 Some("arn:aws:iam::123456789012:user/alice")
2767 );
2768 assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
2769 assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
2770 assert_eq!(
2771 ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
2772 Some("10.0.0.1")
2773 );
2774 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
2775 assert_eq!(ctx.aws_secure_transport, Some(false));
2776 assert!(ctx.aws_current_time.is_some());
2777 assert!(ctx.aws_epoch_time.is_some());
2778 }
2779
2780 #[test]
2781 fn is_secure_transport_reads_x_forwarded_proto() {
2782 let mut headers = http::HeaderMap::new();
2783 headers.insert("x-forwarded-proto", "https".parse().unwrap());
2784 assert!(is_secure_transport(&headers));
2785 headers.insert("x-forwarded-proto", "http".parse().unwrap());
2786 assert!(!is_secure_transport(&headers));
2787 let empty = http::HeaderMap::new();
2788 assert!(!is_secure_transport(&empty));
2789 }
2790
2791 #[test]
2792 fn parse_account_from_arn_extracts_standard_shapes() {
2793 assert_eq!(
2794 parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
2795 Some("123456789012".to_string())
2796 );
2797 assert_eq!(
2798 parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
2799 Some("123456789012".to_string())
2800 );
2801 }
2802
2803 #[test]
2804 fn parse_account_from_arn_returns_none_for_s3_empty_account() {
2805 assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
2807 assert_eq!(
2808 parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
2809 None
2810 );
2811 }
2812
2813 #[test]
2814 fn parse_account_from_arn_returns_none_for_malformed() {
2815 assert_eq!(parse_account_from_arn(""), None);
2816 assert_eq!(parse_account_from_arn("not-an-arn"), None);
2817 assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
2818 assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
2819 }
2820
2821 #[test]
2822 fn extract_region_from_user_agent_finds_region_segment() {
2823 let mut headers = http::HeaderMap::new();
2824 headers.insert(
2825 "user-agent",
2826 "aws-sdk-rust/1.0 os/linux region/eu-central-1"
2827 .parse()
2828 .unwrap(),
2829 );
2830 assert_eq!(
2831 extract_region_from_user_agent(&headers),
2832 Some("eu-central-1".to_string())
2833 );
2834 }
2835
2836 #[test]
2837 fn extract_region_from_user_agent_none_without_header() {
2838 let headers = http::HeaderMap::new();
2839 assert_eq!(extract_region_from_user_agent(&headers), None);
2840 }
2841
2842 #[test]
2843 fn extract_region_from_user_agent_ignores_empty_region() {
2844 let mut headers = http::HeaderMap::new();
2845 headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
2846 assert_eq!(extract_region_from_user_agent(&headers), None);
2847 }
2848
2849 #[test]
2850 fn extract_region_from_user_agent_none_when_no_region_marker() {
2851 let mut headers = http::HeaderMap::new();
2852 headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
2853 assert_eq!(extract_region_from_user_agent(&headers), None);
2854 }
2855
2856 #[test]
2857 fn aws_username_none_for_root() {
2858 let p = Principal {
2859 arn: "arn:aws:iam::123456789012:root".into(),
2860 user_id: "123456789012".into(),
2861 account_id: "123456789012".into(),
2862 principal_type: PrincipalType::Root,
2863 source_identity: None,
2864 tags: None,
2865 };
2866 assert_eq!(aws_username_from_principal(&p), None);
2867 }
2868
2869 #[test]
2870 fn aws_username_bare_no_path() {
2871 let p = Principal {
2872 arn: "arn:aws:iam::123456789012:user/bob".into(),
2873 user_id: "AIDABOB".into(),
2874 account_id: "123456789012".into(),
2875 principal_type: PrincipalType::User,
2876 source_identity: None,
2877 tags: None,
2878 };
2879 assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
2880 }
2881
2882 #[test]
2883 fn principal_type_label_covers_federated_and_unknown() {
2884 assert_eq!(
2885 principal_type_label(PrincipalType::FederatedUser),
2886 "FederatedUser"
2887 );
2888 assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
2889 }
2890
2891 #[test]
2892 fn build_condition_context_marks_secure_when_flag_set() {
2893 let p = Principal {
2894 arn: "arn:aws:iam::123456789012:user/alice".into(),
2895 user_id: "AIDAALICE".into(),
2896 account_id: "123456789012".into(),
2897 principal_type: PrincipalType::User,
2898 source_identity: None,
2899 tags: None,
2900 };
2901 let ctx = build_condition_context(&p, None, "us-west-2", true);
2902 assert_eq!(ctx.aws_secure_transport, Some(true));
2903 assert!(ctx.aws_source_ip.is_none());
2904 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
2905 }
2906
2907 #[test]
2908 fn is_secure_transport_case_insensitive() {
2909 let mut headers = http::HeaderMap::new();
2910 headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
2911 assert!(is_secure_transport(&headers));
2912 }
2913
2914 #[test]
2915 fn is_secure_transport_non_ascii_bytes_false() {
2916 let mut headers = http::HeaderMap::new();
2917 headers.insert(
2918 "x-forwarded-proto",
2919 http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
2920 );
2921 assert!(!is_secure_transport(&headers));
2922 }
2923
2924 #[test]
2925 fn protocol_ext_error_status_is_bad_request() {
2926 assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
2927 assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
2928 assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
2929 assert_eq!(
2930 AwsProtocol::RestJson.error_status(),
2931 StatusCode::BAD_REQUEST
2932 );
2933 }
2934
2935 #[test]
2936 fn build_error_response_json_has_json_content_type() {
2937 let resp = build_error_response(
2938 StatusCode::BAD_REQUEST,
2939 "TestCode",
2940 "test msg",
2941 "req-1",
2942 AwsProtocol::Json,
2943 );
2944 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2945 let ct = resp
2946 .headers()
2947 .get("content-type")
2948 .unwrap()
2949 .to_str()
2950 .unwrap();
2951 assert!(ct.contains("json"));
2952 let rid = resp
2953 .headers()
2954 .get("x-amzn-requestid")
2955 .unwrap()
2956 .to_str()
2957 .unwrap();
2958 assert_eq!(rid, "req-1");
2959 }
2960
2961 #[test]
2962 fn build_error_response_rest_returns_xml_content_type() {
2963 let resp = build_error_response(
2964 StatusCode::NOT_FOUND,
2965 "NoSuchBucket",
2966 "bucket missing",
2967 "req-2",
2968 AwsProtocol::Rest,
2969 );
2970 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2971 let ct = resp
2972 .headers()
2973 .get("content-type")
2974 .unwrap()
2975 .to_str()
2976 .unwrap();
2977 assert!(ct.contains("xml"));
2978 }
2979
2980 fn rest_detected(service: &str) -> protocol::DetectedRequest {
2981 protocol::DetectedRequest {
2982 service: service.to_string(),
2983 action: String::new(),
2984 protocol: AwsProtocol::Rest,
2985 }
2986 }
2987
2988 async fn body_string(resp: Response<Body>) -> String {
2989 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2990 .await
2991 .unwrap();
2992 String::from_utf8(bytes.to_vec()).unwrap()
2993 }
2994
2995 #[tokio::test]
2996 async fn cloudfront_and_route53_errors_use_error_response_wrapper() {
2997 for (service, ns) in [
2998 (
2999 "cloudfront",
3000 "http://cloudfront.amazonaws.com/doc/2020-05-31/",
3001 ),
3002 ("route53", "https://route53.amazonaws.com/doc/2013-04-01/"),
3003 ] {
3004 let resp = build_error_response(
3005 StatusCode::NOT_FOUND,
3006 "NoSuchThing",
3007 "missing",
3008 "req-w",
3009 ErrorEnvelope::for_request(&rest_detected(service), &http::HeaderMap::new()),
3010 );
3011 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
3012 assert_eq!(
3013 resp.headers().get("x-amz-error-code").unwrap(),
3014 "NoSuchThing"
3015 );
3016 let body = body_string(resp).await;
3017 assert!(
3018 body.contains(&format!(
3019 "<ErrorResponse xmlns=\"{ns}\"><Error><Type>Sender</Type>\
3020 <Code>NoSuchThing</Code><Message>missing</Message></Error>\
3021 <RequestId>req-w</RequestId></ErrorResponse>"
3022 )),
3023 "{service}: {body}"
3024 );
3025 }
3026 }
3027
3028 #[tokio::test]
3029 async fn s3_errors_keep_bare_error_document() {
3030 let resp = build_error_response(
3031 StatusCode::NOT_FOUND,
3032 "NoSuchBucket",
3033 "missing",
3034 "req-s3",
3035 ErrorEnvelope::for_request(&rest_detected("s3"), &http::HeaderMap::new()),
3036 );
3037 let body = body_string(resp).await;
3038 assert!(!body.contains("<ErrorResponse"), "{body}");
3039 assert!(body.contains("<Error>"), "{body}");
3040 assert!(body.contains("<Code>NoSuchBucket</Code>"), "{body}");
3041 }
3042
3043 #[tokio::test]
3044 async fn s3_control_errors_use_error_response_wrapper() {
3045 let mut headers = http::HeaderMap::new();
3046 headers.insert(
3047 "host",
3048 "000000000000.s3-control.us-east-1.amazonaws.com"
3049 .parse()
3050 .unwrap(),
3051 );
3052 let resp = build_error_response(
3053 StatusCode::NOT_FOUND,
3054 "NoSuchAccessPoint",
3055 "missing",
3056 "req-ctl",
3057 ErrorEnvelope::for_request(&rest_detected("s3"), &headers),
3058 );
3059 assert_eq!(
3060 resp.headers().get("x-amz-error-code").unwrap(),
3061 "NoSuchAccessPoint"
3062 );
3063 let body = body_string(resp).await;
3064 assert!(
3065 body.contains(
3066 "<ErrorResponse><Error><Code>NoSuchAccessPoint</Code>\
3067 <Message>missing</Message></Error>\
3068 <RequestId>req-ctl</RequestId></ErrorResponse>"
3069 ),
3070 "{body}"
3071 );
3072 }
3073
3074 #[test]
3075 fn build_error_response_query_returns_xml() {
3076 let resp = build_error_response(
3077 StatusCode::BAD_REQUEST,
3078 "InvalidParameter",
3079 "bad param",
3080 "req-3",
3081 AwsProtocol::Query,
3082 );
3083 let ct = resp
3084 .headers()
3085 .get("content-type")
3086 .unwrap()
3087 .to_str()
3088 .unwrap();
3089 assert!(ct.contains("xml"));
3090 }
3091
3092 #[test]
3097 fn build_error_response_with_multiline_message_does_not_panic() {
3098 let resp = build_error_response(
3099 StatusCode::INTERNAL_SERVER_ERROR,
3100 "ServiceException",
3101 "Lambda execution failed: container failed to start: docker start failed: \
3102 Error: unable to start container \"abc\": \
3103 failed to create new hosts file:\nhost-gateway is empty\n",
3104 "req-multi",
3105 AwsProtocol::Json,
3106 );
3107 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3108 let msg = resp
3109 .headers()
3110 .get("x-amz-error-message")
3111 .expect("x-amz-error-message must be set even when input contains newlines")
3112 .to_str()
3113 .unwrap();
3114 assert!(!msg.contains('\n'));
3115 assert!(!msg.contains('\r'));
3116 assert!(msg.contains("Lambda execution failed"));
3117 assert!(msg.contains("host-gateway is empty"));
3118 }
3119
3120 #[test]
3121 fn build_error_response_with_control_chars_strips_them() {
3122 let resp = build_error_response(
3123 StatusCode::BAD_REQUEST,
3124 "Code\twith\ttabs",
3125 "msg\x00with\x01nulls",
3126 "req-ctrl",
3127 AwsProtocol::Json,
3128 );
3129 let code = resp
3130 .headers()
3131 .get("x-amz-error-code")
3132 .unwrap()
3133 .to_str()
3134 .unwrap();
3135 let msg = resp
3136 .headers()
3137 .get("x-amz-error-message")
3138 .unwrap()
3139 .to_str()
3140 .unwrap();
3141 assert!(!code.contains('\t'));
3142 assert!(!msg.contains('\x00'));
3143 assert!(!msg.contains('\x01'));
3144 }
3145
3146 #[test]
3147 fn sanitize_header_value_truncates_long_input() {
3148 let huge = "x".repeat(5_000);
3149 let out = sanitize_header_value(&huge);
3150 assert!(out.len() <= 1024);
3151 }
3152
3153 #[test]
3154 fn sanitize_header_value_collapses_consecutive_control_runs() {
3155 let out = sanitize_header_value("a\n\n\n\rb");
3156 assert_eq!(out, "a b");
3157 }
3158
3159 #[test]
3160 fn anonymous_s3_probe_finds_a_bucket_on_a_china_server() {
3161 struct RecordingProvider(parking_lot::Mutex<Vec<String>>);
3165 impl crate::auth::ResourcePolicyProvider for RecordingProvider {
3166 fn resource_policy(&self, _service: &str, _resource_arn: &str) -> Option<String> {
3167 None
3168 }
3169 fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
3170 self.0.lock().push(resource_arn.to_string());
3171 resource_arn
3172 .strip_prefix("arn:aws:s3:::")
3173 .filter(|bucket| *bucket == "my-bucket")
3174 .map(|_| "000000000000".to_string())
3175 }
3176 }
3177 let provider = Arc::new(RecordingProvider(parking_lot::Mutex::new(Vec::new())));
3178 let mut cfg = DispatchConfig::new("cn-north-1", "000000000000");
3179 cfg.resource_policy_provider = Some(provider.clone());
3180 let uri: http::Uri = "/my-bucket/key.txt".parse().unwrap();
3181 assert_eq!(
3182 anonymous_s3_bucket(&uri, &cfg),
3183 Some("my-bucket".to_string())
3184 );
3185 assert_eq!(
3186 *provider.0.lock(),
3187 vec!["arn:aws:s3:::my-bucket".to_string()]
3188 );
3189 }
3190
3191 #[test]
3192 fn dispatch_config_carries_opt_in_flags() {
3193 let cfg = DispatchConfig {
3194 region: "eu-west-1".to_string(),
3195 account_id: "000000000000".to_string(),
3196 verify_sigv4: true,
3197 iam_mode: IamMode::Strict,
3198 credential_resolver: None,
3199 policy_evaluator: None,
3200 resource_policy_provider: None,
3201 scp_resolver: None,
3202 };
3203 assert!(cfg.verify_sigv4);
3204 assert!(cfg.iam_mode.is_strict());
3205 assert!(cfg.resource_policy_provider.is_none());
3206 assert!(cfg.scp_resolver.is_none());
3207 }
3208
3209 fn s3_sigv4_headers() -> http::HeaderMap {
3210 let mut headers = http::HeaderMap::new();
3211 headers.insert(
3212 "authorization",
3213 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, \
3214 SignedHeaders=host, Signature=fake"
3215 .parse()
3216 .unwrap(),
3217 );
3218 headers
3219 }
3220
3221 #[test]
3222 fn streaming_route_path_style_s3_put_object() {
3223 let headers = s3_sigv4_headers();
3224 assert_eq!(
3225 streaming_route(
3226 &http::Method::PUT,
3227 "/my-bucket/key.txt",
3228 &headers,
3229 &HashMap::new(),
3230 ),
3231 Some(("s3", "")),
3232 );
3233 }
3234
3235 #[test]
3236 fn streaming_route_path_style_create_bucket_skipped() {
3237 let headers = s3_sigv4_headers();
3240 assert_eq!(
3241 streaming_route(&http::Method::PUT, "/my-bucket", &headers, &HashMap::new(),),
3242 None,
3243 );
3244 }
3245
3246 #[test]
3247 fn s3_routing_path_prefixes_the_host_bucket() {
3248 assert_eq!(s3_routing_path("/", Some("b")), "/b");
3249 assert_eq!(s3_routing_path("", Some("b")), "/b");
3250 assert_eq!(s3_routing_path("/k.txt", Some("b")), "/b/k.txt");
3251 assert_eq!(s3_routing_path("/dir/k", Some("a.b")), "/a.b/dir/k");
3252 assert_eq!(s3_routing_path("/b/k", None), "/b/k");
3253 }
3254
3255 #[test]
3256 fn s3_routing_path_keeps_a_key_that_starts_with_the_bucket_name() {
3257 assert_eq!(
3260 s3_routing_path("/docs/intro.html", Some("docs")),
3261 "/docs/docs/intro.html"
3262 );
3263 assert_eq!(s3_routing_path("/docs", Some("docs")), "/docs/docs");
3264 }
3265
3266 #[test]
3267 fn streaming_route_path_style_create_bucket_with_trailing_slash_skipped() {
3268 let headers = s3_sigv4_headers();
3273 assert_eq!(
3274 streaming_route(&http::Method::PUT, "/my-bucket/", &headers, &HashMap::new(),),
3275 None,
3276 );
3277 }
3278
3279 #[test]
3280 fn streaming_route_path_style_doubled_slash_skipped() {
3281 let headers = s3_sigv4_headers();
3286 assert_eq!(
3287 streaming_route(
3288 &http::Method::PUT,
3289 "/my-bucket//",
3290 &headers,
3291 &HashMap::new()
3292 ),
3293 None,
3294 );
3295 }
3296
3297 #[test]
3298 fn streaming_route_path_style_key_with_trailing_slash_streams() {
3299 let headers = s3_sigv4_headers();
3302 assert_eq!(
3303 streaming_route(
3304 &http::Method::PUT,
3305 "/my-bucket/folder/",
3306 &headers,
3307 &HashMap::new(),
3308 ),
3309 Some(("s3", "")),
3310 );
3311 }
3312
3313 #[test]
3314 fn streaming_route_virtual_hosted_s3_put_object() {
3315 let mut headers = s3_sigv4_headers();
3316 headers.insert(
3317 "host",
3318 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
3319 .parse()
3320 .unwrap(),
3321 );
3322 assert_eq!(
3327 streaming_route(&http::Method::PUT, "/hello.txt", &headers, &HashMap::new(),),
3328 Some(("s3", "")),
3329 );
3330 }
3331
3332 #[test]
3333 fn streaming_route_virtual_hosted_path_naming_the_bucket_streams() {
3334 let mut headers = s3_sigv4_headers();
3339 headers.insert(
3340 "host",
3341 "my-bucket.s3.us-east-1.amazonaws.com".parse().unwrap(),
3342 );
3343 for path in ["/my-bucket", "/my-bucket/", "/my-bucket/key.txt"] {
3344 assert_eq!(
3345 streaming_route(&http::Method::PUT, path, &headers, &HashMap::new()),
3346 Some(("s3", "")),
3347 "{path}",
3348 );
3349 }
3350 }
3351
3352 #[test]
3353 fn streaming_route_virtual_hosted_s3_root_skipped() {
3354 let mut headers = s3_sigv4_headers();
3357 headers.insert(
3358 "host",
3359 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
3360 .parse()
3361 .unwrap(),
3362 );
3363 assert_eq!(
3364 streaming_route(&http::Method::PUT, "/", &headers, &HashMap::new()),
3365 None,
3366 );
3367 }
3368
3369 #[test]
3370 fn streaming_route_ecr_blob_upload() {
3371 let headers = http::HeaderMap::new();
3372 assert_eq!(
3373 streaming_route(
3374 &http::Method::PATCH,
3375 "/v2/my-repo/blobs/uploads/abcd1234",
3376 &headers,
3377 &HashMap::new(),
3378 ),
3379 Some(("ecr", "")),
3380 );
3381 assert_eq!(
3382 streaming_route(
3383 &http::Method::PUT,
3384 "/v2/my-repo/blobs/uploads/abcd1234",
3385 &headers,
3386 &HashMap::new(),
3387 ),
3388 Some(("ecr", "")),
3389 );
3390 }
3391
3392 #[test]
3393 fn hoist_presigned_query_headers_skips_auth_params_and_keeps_direct_headers() {
3394 let mut headers = http::HeaderMap::new();
3395 headers.insert("x-amz-meta-color", "red".parse().unwrap());
3396 let query: HashMap<String, String> = [
3397 (
3398 "X-Amz-Credential",
3399 "AKID/20260101/us-east-1/s3/aws4_request",
3400 ),
3401 ("X-Amz-Signature", "00"),
3402 ("X-Amz-Security-Token", "tok"),
3403 ("x-amz-meta-color", "blue"),
3404 ("X-Amz-Meta-Shape", "round"),
3405 ("x-amz-meta-name", "café"),
3406 ("x-amz-tagging", "env=test"),
3407 ("response-content-type", "text/plain"),
3408 ("partNumber", "1"),
3409 ]
3410 .into_iter()
3411 .map(|(k, v)| (k.to_string(), v.to_string()))
3412 .collect();
3413 hoist_presigned_query_headers(&mut headers, &query);
3414
3415 assert_eq!(headers["x-amz-meta-color"], "red");
3416 assert_eq!(headers["x-amz-meta-shape"], "round");
3417 assert_eq!(headers["x-amz-meta-name"], "=?UTF-8?B?Y2Fmw6k=?=");
3418 assert_eq!(headers["x-amz-tagging"], "env=test");
3419 for absent in [
3420 "x-amz-credential",
3421 "x-amz-signature",
3422 "x-amz-security-token",
3423 "response-content-type",
3424 "partnumber",
3425 ] {
3426 assert!(headers.get(absent).is_none(), "{absent}");
3427 }
3428 }
3429
3430 #[test]
3431 fn streaming_route_presigned_v4_s3_put() {
3432 let headers = http::HeaderMap::new();
3433 let mut query_params = HashMap::new();
3434 query_params.insert(
3435 "X-Amz-Credential".to_string(),
3436 "test/20240101/us-east-1/s3/aws4_request".to_string(),
3437 );
3438 assert_eq!(
3439 streaming_route(
3440 &http::Method::PUT,
3441 "/my-bucket/key.txt",
3442 &headers,
3443 &query_params,
3444 ),
3445 Some(("s3", "")),
3446 );
3447 }
3448
3449 #[test]
3450 fn streaming_route_non_s3_auth_header_skipped() {
3451 let mut headers = http::HeaderMap::new();
3454 headers.insert(
3455 "authorization",
3456 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/lambda/aws4_request, \
3457 SignedHeaders=host, Signature=fake"
3458 .parse()
3459 .unwrap(),
3460 );
3461 assert_eq!(
3462 streaming_route(
3463 &http::Method::PUT,
3464 "/my-bucket/key.txt",
3465 &headers,
3466 &HashMap::new(),
3467 ),
3468 None,
3469 );
3470 }
3471
3472 #[test]
3473 fn streaming_route_get_skipped() {
3474 let headers = s3_sigv4_headers();
3475 assert_eq!(
3476 streaming_route(
3477 &http::Method::GET,
3478 "/my-bucket/key.txt",
3479 &headers,
3480 &HashMap::new(),
3481 ),
3482 None,
3483 );
3484 }
3485
3486 #[test]
3491 fn root_cross_account_needs_the_resource_policy() {
3492 use crate::auth::IamAction;
3493 use crate::service::{AwsResponse, AwsServiceError};
3494 struct OwnerProvider;
3495 impl crate::auth::ResourcePolicyProvider for OwnerProvider {
3496 fn resource_policy(&self, _service: &str, resource_arn: &str) -> Option<String> {
3497 resource_arn
3498 .starts_with("arn:aws:s3:::granted")
3499 .then(|| "granting-policy".to_string())
3500 }
3501 fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
3502 let bucket = resource_arn.strip_prefix("arn:aws:s3:::")?;
3503 let bucket = bucket.split('/').next()?;
3504 match bucket {
3505 "own" => Some("222222222222".to_string()),
3506 "granted" | "denied" => Some("111111111111".to_string()),
3507 _ => None,
3508 }
3509 }
3510 }
3511 struct PolicyEvaluator(parking_lot::Mutex<Vec<ConditionContext>>);
3512 impl IamPolicyEvaluator for PolicyEvaluator {
3513 fn evaluate(
3514 &self,
3515 _: &Principal,
3516 _: &IamAction,
3517 _: &ConditionContext,
3518 _: &[String],
3519 _: Option<&[String]>,
3520 ) -> crate::auth::IamDecision {
3521 crate::auth::IamDecision::ImplicitDeny
3522 }
3523 fn evaluate_with_resource_policy(
3524 &self,
3525 _: &Principal,
3526 _: &IamAction,
3527 _: &ConditionContext,
3528 _: Option<&str>,
3529 _: &str,
3530 _: &[String],
3531 _: Option<&[String]>,
3532 ) -> crate::auth::IamDecision {
3533 crate::auth::IamDecision::ImplicitDeny
3534 }
3535 fn evaluate_resource_policy_only(
3536 &self,
3537 _: &Principal,
3538 _: &IamAction,
3539 context: &ConditionContext,
3540 policy: Option<&str>,
3541 ) -> crate::auth::IamDecision {
3542 self.0.lock().push(context.clone());
3543 if policy == Some("granting-policy") {
3544 crate::auth::IamDecision::Allow
3545 } else {
3546 crate::auth::IamDecision::ImplicitDeny
3547 }
3548 }
3549 }
3550 struct BucketService;
3551 #[async_trait::async_trait]
3552 impl crate::service::AwsService for BucketService {
3553 fn service_name(&self) -> &str {
3554 "s3"
3555 }
3556 async fn handle(&self, _: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
3557 unreachable!()
3558 }
3559 fn supported_actions(&self) -> &[&str] {
3560 &[]
3561 }
3562 fn iam_action_for(&self, request: &AwsRequest) -> Option<IamAction> {
3563 Some(IamAction {
3564 service: "s3",
3565 action: "GetObject",
3566 resource: format!("arn:aws:s3:::{}", request.path_segments.join("/")),
3567 })
3568 }
3569 }
3570 let root = Principal {
3571 arn: "arn:aws:iam::222222222222:root".to_string(),
3572 user_id: "222222222222".to_string(),
3573 account_id: "222222222222".to_string(),
3574 principal_type: PrincipalType::Root,
3575 source_identity: None,
3576 tags: None,
3577 };
3578 let detected = protocol::DetectedRequest {
3579 service: "s3".to_string(),
3580 action: String::new(),
3581 protocol: AwsProtocol::Rest,
3582 };
3583 let request = |bucket: &str| AwsRequest {
3584 service: "s3".to_string(),
3585 action: String::new(),
3586 region: "us-east-1".to_string(),
3587 account_id: "222222222222".to_string(),
3588 request_id: "req".to_string(),
3589 headers: http::HeaderMap::new(),
3590 query_params: HashMap::new(),
3591 body: Bytes::new(),
3592 body_stream: parking_lot::Mutex::new(None),
3593 path_segments: vec![bucket.to_string(), "key".to_string()],
3594 raw_path: format!("/{bucket}/key"),
3595 raw_query: String::new(),
3596 method: http::Method::GET,
3597 is_query_protocol: false,
3598 access_key_id: Some("AKIAROOTB".to_string()),
3599 principal: Some(root.clone()),
3600 };
3601 struct Org;
3602 impl crate::auth::ScpResolver for Org {
3603 fn scps_for(&self, _: &Principal) -> Option<Vec<String>> {
3604 None
3605 }
3606 fn principal_org(&self, account: &str) -> Option<(String, String)> {
3607 (account == "222222222222").then(|| ("o-abc".to_string(), "o-abc/r-1/".to_string()))
3608 }
3609 }
3610 let issued = chrono::Utc::now();
3611 let credential = crate::auth::ResolvedCredential {
3612 secret_access_key: "secret".to_string(),
3613 session_token: Some("token".to_string()),
3614 principal: root.clone(),
3615 session_policies: Vec::new(),
3616 mfa_present: true,
3617 token_issued_at: Some(issued),
3618 federated_provider: None,
3619 };
3620 let evaluator = PolicyEvaluator(parking_lot::Mutex::new(Vec::new()));
3621 let run = |bucket: &str, mode: IamMode| {
3622 let mut cfg = DispatchConfig::new("us-east-1", "111111111111");
3623 cfg.iam_mode = mode;
3624 cfg.resource_policy_provider = Some(Arc::new(OwnerProvider));
3625 cfg.scp_resolver = Some(Arc::new(Org));
3626 authorize_root_cross_account(
3627 &root,
3628 &BucketService,
3629 &request(bucket),
3630 &evaluator,
3631 &cfg,
3632 &detected,
3633 "req",
3634 None,
3635 Some(&credential),
3636 )
3637 };
3638 let denied = run("denied", IamMode::Strict).expect("no grant must deny under strict");
3639 assert_eq!(denied.status(), StatusCode::FORBIDDEN);
3640 assert!(run("granted", IamMode::Strict).is_none());
3641 assert!(run("own", IamMode::Strict).is_none());
3642 assert!(run("unclaimed", IamMode::Strict).is_none());
3643 assert!(run("denied", IamMode::Soft).is_none());
3644
3645 let contexts = evaluator.0.lock();
3649 assert!(!contexts.is_empty());
3650 for ctx in contexts.iter() {
3651 assert_eq!(
3652 ctx.lookup("aws:ResourceAccount"),
3653 Some(vec!["111111111111".into()])
3654 );
3655 assert_eq!(ctx.lookup("aws:PrincipalOrgID"), Some(vec!["o-abc".into()]));
3656 assert_eq!(
3657 ctx.lookup("aws:PrincipalOrgPaths"),
3658 Some(vec!["o-abc/r-1/".into()])
3659 );
3660 assert_eq!(ctx.aws_mfa_present, Some(true));
3661 assert_eq!(ctx.aws_token_issue_time, Some(issued));
3662 assert_eq!(ctx.aws_principal_type.as_deref(), Some("Account"));
3663 }
3664 }
3665}
3666
3667fn is_execute_api_host(headers: &http::HeaderMap) -> bool {
3670 headers
3671 .get(http::header::HOST)
3672 .and_then(|v| v.to_str().ok())
3673 .is_some_and(|host| host.contains(".execute-api."))
3674}
3675
3676#[cfg(test)]
3677mod execute_api_host_tests {
3678 use super::is_execute_api_host;
3679
3680 #[test]
3681 fn recognizes_execute_api_hosts_only() {
3682 let mut h = http::HeaderMap::new();
3683 h.insert(
3684 http::header::HOST,
3685 "abc123.execute-api.us-east-1.amazonaws.com"
3686 .parse()
3687 .unwrap(),
3688 );
3689 assert!(is_execute_api_host(&h));
3690 h.insert(http::header::HOST, "localhost:4566".parse().unwrap());
3691 assert!(!is_execute_api_host(&h));
3692 assert!(!is_execute_api_host(&http::HeaderMap::new()));
3693 }
3694}