1use std::convert::Infallible;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27
28use bytes::Bytes;
29use http_body_util::Full;
30use hyper::body::Incoming;
31use hyper::service::Service;
32use hyper::{Method, Request, Response, StatusCode};
33use metrics_exporter_prometheus::PrometheusHandle;
34
35use crate::cors::{CorsManager, CorsRule};
36use crate::service::SigV4aGate;
37
38pub type ReadyCheck =
40 Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
41
42#[derive(Clone)]
45pub struct HealthRouter<S> {
46 pub inner: S,
47 pub ready_check: Option<ReadyCheck>,
48 pub metrics_handle: Option<PrometheusHandle>,
49 pub cors_manager: Option<Arc<CorsManager>>,
56 pub sigv4a_gate: Option<Arc<SigV4aGate>>,
63 pub region: String,
70}
71
72impl<S> HealthRouter<S> {
73 pub fn new(inner: S, ready_check: Option<ReadyCheck>) -> Self {
74 Self {
75 inner,
76 ready_check,
77 metrics_handle: None,
78 cors_manager: None,
79 sigv4a_gate: None,
80 region: "us-east-1".to_string(),
81 }
82 }
83
84 #[must_use]
85 pub fn with_metrics(mut self, handle: PrometheusHandle) -> Self {
86 self.metrics_handle = Some(handle);
87 self
88 }
89
90 #[must_use]
94 pub fn with_cors_manager(mut self, mgr: Arc<CorsManager>) -> Self {
95 self.cors_manager = Some(mgr);
96 self
97 }
98
99 #[must_use]
103 pub fn with_sigv4a_gate(mut self, gate: Arc<SigV4aGate>) -> Self {
104 self.sigv4a_gate = Some(gate);
105 self
106 }
107
108 #[must_use]
112 pub fn with_region(mut self, region: impl Into<String>) -> Self {
113 self.region = region.into();
114 self
115 }
116}
117
118#[must_use]
137pub fn try_handle_preflight<B>(
138 req: &Request<B>,
139 cors: Option<&Arc<CorsManager>>,
140) -> Option<Response<s3s::Body>> {
141 if req.method() != Method::OPTIONS {
142 return None;
143 }
144 let mgr = cors?;
145 let path = req.uri().path();
149 let bucket = path.trim_start_matches('/').split('/').next()?;
150 if bucket.is_empty() {
151 return None;
152 }
153 let origin = req.headers().get("origin")?.to_str().ok()?;
154 let method = req
155 .headers()
156 .get("access-control-request-method")?
157 .to_str()
158 .ok()?;
159 let req_headers: Vec<String> = req
162 .headers()
163 .get("access-control-request-headers")
164 .and_then(|h| h.to_str().ok())
165 .map(|s| {
166 s.split(',')
167 .map(|t| t.trim().to_string())
168 .filter(|t| !t.is_empty())
169 .collect()
170 })
171 .unwrap_or_default();
172 let _ = mgr.get(bucket)?;
176 match mgr.match_preflight(bucket, origin, method, &req_headers) {
177 Some(rule) => Some(build_preflight_allow_response(&rule, origin)),
178 None => Some(build_preflight_deny_response()),
179 }
180}
181
182fn build_preflight_allow_response(rule: &CorsRule, origin: &str) -> Response<s3s::Body> {
184 let mut builder = Response::builder().status(StatusCode::OK);
185 let allow_origin: String = if rule.allowed_origins.iter().any(|o| o == "*") {
188 "*".into()
189 } else {
190 origin.to_owned()
191 };
192 builder = builder.header("Access-Control-Allow-Origin", allow_origin);
193 builder = builder.header(
194 "Access-Control-Allow-Methods",
195 rule.allowed_methods.join(", "),
196 );
197 if !rule.allowed_headers.is_empty() {
198 builder = builder.header(
199 "Access-Control-Allow-Headers",
200 rule.allowed_headers.join(", "),
201 );
202 }
203 if !rule.expose_headers.is_empty() {
204 builder = builder.header(
205 "Access-Control-Expose-Headers",
206 rule.expose_headers.join(", "),
207 );
208 }
209 if let Some(secs) = rule.max_age_seconds {
210 builder = builder.header("Access-Control-Max-Age", secs.to_string());
211 }
212 let bytes = Bytes::new();
214 builder = builder.header("content-length", "0");
215 builder
216 .body(s3s::Body::http_body(
217 Full::new(bytes).map_err(|never| match never {}),
218 ))
219 .expect("preflight response builder")
220}
221
222fn build_preflight_deny_response() -> Response<s3s::Body> {
225 let body = Bytes::from_static(b"CORSResponse: This CORS request is not allowed.");
226 Response::builder()
227 .status(StatusCode::FORBIDDEN)
228 .header("content-type", "text/plain; charset=utf-8")
229 .header("content-length", body.len().to_string())
230 .body(s3s::Body::http_body(
231 Full::new(body).map_err(|never| match never {}),
232 ))
233 .expect("preflight deny response builder")
234}
235
236pub fn try_sigv4a_verify<B>(
279 req: &Request<B>,
280 gate: Option<&Arc<SigV4aGate>>,
281 requested_region: &str,
282) -> Option<Result<(), Response<s3s::Body>>> {
283 let gate = gate?;
284 if !crate::sigv4a::detect(req) {
285 return None;
287 }
288 let auth_hdr = req
293 .headers()
294 .get(http::header::AUTHORIZATION)
295 .and_then(|v| v.to_str().ok());
296 let signed_headers: Vec<String> = match auth_hdr
297 .and_then(crate::sigv4a::parse_authorization_header)
298 {
299 Some(parsed) => parsed.signed_headers,
300 None => {
301 return Some(Err(build_sigv4a_error_response(
305 "SignatureDoesNotMatch",
306 "missing or malformed Authorization header for SigV4a request",
307 )));
308 }
309 };
310 let canonical = build_canonical_request_bytes(req, &signed_headers);
311 match gate.pre_route(req, requested_region, &canonical) {
312 Ok(()) => Some(Ok(())),
313 Err(err) => {
314 tracing::warn!(error = %err, "SigV4a verify rejected request");
315 Some(Err(build_sigv4a_error_response(
316 err.s3_error_code(),
317 &err.to_string(),
318 )))
319 }
320 }
321}
322
323fn build_canonical_request_bytes<B>(
339 req: &Request<B>,
340 signed_headers: &[String],
341) -> Vec<u8> {
342 let mut buf = String::with_capacity(512);
343 buf.push_str(req.method().as_str());
344 buf.push('\n');
345 buf.push_str(req.uri().path());
346 buf.push('\n');
347 buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
348 buf.push('\n');
349 for name in signed_headers {
350 let value = req
351 .headers()
352 .get(name.as_str())
353 .and_then(|v| v.to_str().ok())
354 .unwrap_or("");
355 buf.push_str(name);
356 buf.push(':');
357 buf.push_str(&trim_collapse_ws(value));
361 buf.push('\n');
362 }
363 buf.push('\n');
364 buf.push_str(&signed_headers.join(";"));
365 buf.push('\n');
366 let payload_hash = req
367 .headers()
368 .get("x-amz-content-sha256")
369 .and_then(|v| v.to_str().ok())
370 .unwrap_or("UNSIGNED-PAYLOAD");
371 buf.push_str(payload_hash);
372 buf.into_bytes()
373}
374
375fn canonical_query_string(query: &str) -> String {
381 if query.is_empty() {
382 return String::new();
383 }
384 let mut pairs: Vec<(&str, &str)> = query
385 .split('&')
386 .filter(|s| !s.is_empty())
387 .map(|kv| match kv.split_once('=') {
388 Some((k, v)) => (k, v),
389 None => (kv, ""),
390 })
391 .collect();
392 pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
393 let mut out = String::with_capacity(query.len());
394 for (i, (k, v)) in pairs.iter().enumerate() {
395 if i > 0 {
396 out.push('&');
397 }
398 out.push_str(k);
399 out.push('=');
400 out.push_str(v);
401 }
402 out
403}
404
405fn trim_collapse_ws(s: &str) -> String {
411 let trimmed = s.trim();
412 let mut out = String::with_capacity(trimmed.len());
413 let mut prev_ws = false;
414 for c in trimmed.chars() {
415 if c.is_whitespace() {
416 if !prev_ws {
417 out.push(' ');
418 }
419 prev_ws = true;
420 } else {
421 out.push(c);
422 prev_ws = false;
423 }
424 }
425 out
426}
427
428fn build_sigv4a_error_response(code: &str, message: &str) -> Response<s3s::Body> {
433 let body_str = format!(
434 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
435 <Error>\n <Code>{code}</Code>\n <Message>{message}</Message>\n</Error>"
436 );
437 let bytes = Bytes::from(body_str.into_bytes());
438 Response::builder()
439 .status(StatusCode::FORBIDDEN)
440 .header("content-type", "application/xml")
441 .header("content-length", bytes.len().to_string())
442 .body(s3s::Body::http_body(
443 Full::new(bytes).map_err(|never| match never {}),
444 ))
445 .expect("sigv4a error response builder")
446}
447
448
449type RespBody = s3s::Body;
453
454fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
455 let bytes = Bytes::from_static(body.as_bytes());
456 Response::builder()
457 .status(status)
458 .header("content-type", "text/plain; charset=utf-8")
459 .header("content-length", bytes.len().to_string())
460 .body(s3s::Body::http_body(
461 Full::new(bytes).map_err(|never| match never {}),
462 ))
463 .expect("static response")
464}
465
466fn make_owned_text_response(
467 status: StatusCode,
468 content_type: &'static str,
469 body: String,
470) -> Response<RespBody> {
471 let bytes = Bytes::from(body.into_bytes());
472 Response::builder()
473 .status(status)
474 .header("content-type", content_type)
475 .header("content-length", bytes.len().to_string())
476 .body(s3s::Body::http_body(
477 Full::new(bytes).map_err(|never| match never {}),
478 ))
479 .expect("owned response")
480}
481
482impl<S> Service<Request<Incoming>> for HealthRouter<S>
483where
484 S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
485 + Clone
486 + Send
487 + 'static,
488 S::Future: Send + 'static,
489{
490 type Response = Response<RespBody>;
491 type Error = s3s::HttpError;
492 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
493
494 fn call(&self, req: Request<Incoming>) -> Self::Future {
495 if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
501 return Box::pin(async move { Ok(resp) });
502 }
503 if let Some(result) =
510 try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region)
511 {
512 match result {
513 Ok(()) => {
514 }
517 Err(resp) => return Box::pin(async move { Ok(resp) }),
518 }
519 }
520 let path = req.uri().path();
521 match (req.method(), path) {
522 (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
523 Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
524 }
525 (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
526 let handle = self.metrics_handle.clone();
527 Box::pin(async move {
528 match handle {
529 Some(h) => {
530 let body = h.render();
531 Ok(make_owned_text_response(
532 StatusCode::OK,
533 "text/plain; version=0.0.4; charset=utf-8",
534 body,
535 ))
536 }
537 None => Ok(make_text_response(
538 StatusCode::SERVICE_UNAVAILABLE,
539 "metrics not configured\n",
540 )),
541 }
542 })
543 }
544 (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
545 let check = self.ready_check.clone();
546 Box::pin(async move {
547 match check {
548 Some(f) => match f().await {
549 Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
550 Err(reason) => {
551 tracing::warn!(%reason, "readiness check failed");
552 Ok(make_text_response(
553 StatusCode::SERVICE_UNAVAILABLE,
554 "not ready\n",
555 ))
556 }
557 },
558 None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
559 }
560 })
561 }
562 _ => {
563 let inner = self.inner.clone();
564 Box::pin(async move { inner.call(req).await })
565 }
566 }
567 }
568}
569
570trait FullExt<B> {
572 fn map_err<E, F: FnMut(Infallible) -> E>(
573 self,
574 f: F,
575 ) -> http_body_util::combinators::MapErr<Self, F>
576 where
577 Self: Sized;
578}
579impl<B> FullExt<B> for Full<B>
580where
581 B: bytes::Buf,
582{
583 fn map_err<E, F: FnMut(Infallible) -> E>(
584 self,
585 f: F,
586 ) -> http_body_util::combinators::MapErr<Self, F>
587 where
588 Self: Sized,
589 {
590 http_body_util::BodyExt::map_err(self, f)
591 }
592}
593
594#[cfg(test)]
595mod preflight_tests {
596 use super::*;
611 use crate::cors::{CorsConfig, CorsManager, CorsRule};
612
613 fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
614 CorsRule {
615 allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
616 allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
617 allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
618 expose_headers: vec!["ETag".into()],
619 max_age_seconds: Some(600),
620 id: Some("test".into()),
621 }
622 }
623
624 fn req(
627 method: Method,
628 path: &str,
629 headers: &[(&str, &str)],
630 ) -> Request<()> {
631 let mut b = Request::builder().method(method).uri(path);
632 for (k, v) in headers {
633 b = b.header(*k, *v);
634 }
635 b.body(()).expect("request builder")
636 }
637
638 fn manager_with_rule() -> Arc<CorsManager> {
639 let mgr = CorsManager::new();
640 mgr.put(
641 "b",
642 CorsConfig {
643 rules: vec![rule(
644 &["https://app.example.com"],
645 &["GET", "PUT", "DELETE"],
646 &["Content-Type", "X-Amz-Date"],
647 )],
648 },
649 );
650 Arc::new(mgr)
651 }
652
653 #[test]
654 fn preflight_match_returns_allow_response() {
655 let mgr = manager_with_rule();
656 let r = req(
657 Method::OPTIONS,
658 "/b/key.txt",
659 &[
660 ("origin", "https://app.example.com"),
661 ("access-control-request-method", "PUT"),
662 ("access-control-request-headers", "content-type, x-amz-date"),
663 ],
664 );
665 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
666 assert_eq!(resp.status(), StatusCode::OK);
667 let h = resp.headers();
668 assert_eq!(
669 h.get("access-control-allow-origin")
670 .and_then(|v| v.to_str().ok()),
671 Some("https://app.example.com")
672 );
673 assert_eq!(
674 h.get("access-control-allow-methods")
675 .and_then(|v| v.to_str().ok()),
676 Some("GET, PUT, DELETE")
677 );
678 assert_eq!(
679 h.get("access-control-allow-headers")
680 .and_then(|v| v.to_str().ok()),
681 Some("Content-Type, X-Amz-Date")
682 );
683 assert_eq!(
684 h.get("access-control-max-age")
685 .and_then(|v| v.to_str().ok()),
686 Some("600")
687 );
688 assert_eq!(
689 h.get("access-control-expose-headers")
690 .and_then(|v| v.to_str().ok()),
691 Some("ETag")
692 );
693 }
694
695 #[test]
696 fn preflight_no_match_returns_403() {
697 let mgr = manager_with_rule();
698 let r = req(
702 Method::OPTIONS,
703 "/b/key.txt",
704 &[
705 ("origin", "https://evil.example.com"),
706 ("access-control-request-method", "PUT"),
707 ],
708 );
709 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
710 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
711 assert!(resp.headers().get("access-control-allow-origin").is_none());
713 }
714
715 #[test]
716 fn preflight_no_origin_falls_through() {
717 let mgr = manager_with_rule();
720 let r = req(
721 Method::OPTIONS,
722 "/b/key.txt",
723 &[("access-control-request-method", "PUT")],
724 );
725 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
726 }
727
728 #[test]
729 fn non_options_falls_through() {
730 let mgr = manager_with_rule();
731 let r = req(
733 Method::GET,
734 "/b/key.txt",
735 &[
736 ("origin", "https://app.example.com"),
737 ("access-control-request-method", "PUT"),
738 ],
739 );
740 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
741 }
742
743 #[test]
744 fn no_cors_config_for_bucket_falls_through() {
745 let mgr = manager_with_rule();
748 let r = req(
749 Method::OPTIONS,
750 "/ghost/key.txt",
751 &[
752 ("origin", "https://app.example.com"),
753 ("access-control-request-method", "PUT"),
754 ],
755 );
756 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
757 }
758
759 #[test]
760 fn no_manager_attached_falls_through() {
761 let r = req(
762 Method::OPTIONS,
763 "/b/key.txt",
764 &[
765 ("origin", "https://app.example.com"),
766 ("access-control-request-method", "PUT"),
767 ],
768 );
769 assert!(try_handle_preflight(&r, None).is_none());
770 }
771
772 #[test]
773 fn preflight_wildcard_origin_echoes_star() {
774 let mgr = CorsManager::new();
776 mgr.put(
777 "b",
778 CorsConfig {
779 rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
780 },
781 );
782 let mgr = Arc::new(mgr);
783 let r = req(
784 Method::OPTIONS,
785 "/b/key",
786 &[
787 ("origin", "https://anywhere.example"),
788 ("access-control-request-method", "PUT"),
789 ("access-control-request-headers", "x-custom-header"),
790 ],
791 );
792 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
793 assert_eq!(resp.status(), StatusCode::OK);
794 assert_eq!(
795 resp.headers()
796 .get("access-control-allow-origin")
797 .and_then(|v| v.to_str().ok()),
798 Some("*"),
799 "wildcard rule must echo literal '*' instead of requesting origin"
800 );
801 }
802
803 #[test]
804 fn preflight_empty_path_falls_through() {
805 let mgr = manager_with_rule();
806 let r = req(
807 Method::OPTIONS,
808 "/",
809 &[
810 ("origin", "https://app.example.com"),
811 ("access-control-request-method", "PUT"),
812 ],
813 );
814 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
815 }
816}
817
818#[cfg(test)]
819mod sigv4a_gate_tests {
820 use super::*;
848
849 use std::collections::HashMap;
850
851 use http_body_util::BodyExt;
852 use p256::ecdsa::SigningKey;
853 use p256::ecdsa::signature::Signer;
854 use rand::rngs::OsRng;
855
856 use crate::service::SigV4aGate;
857 use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
858
859 fn lower_hex(bytes: &[u8]) -> String {
860 let mut s = String::with_capacity(bytes.len() * 2);
861 for b in bytes {
862 s.push_str(&format!("{b:02x}"));
863 }
864 s
865 }
866
867 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
869 let mut b = Request::builder().method(method).uri(path);
870 for (k, v) in headers {
871 b = b.header(*k, *v);
872 }
873 b.body(()).expect("request builder")
874 }
875
876 fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
879 format!(
880 "AWS4-ECDSA-P256-SHA256 \
881 Credential={access_key}/20260513/s3/aws4_request, \
882 SignedHeaders={}, \
883 Signature={sig_hex}",
884 signed_headers.join(";")
885 )
886 }
887
888 fn make_signed_request(
892 access_key: &str,
893 method: Method,
894 path: &str,
895 region_set: &str,
896 ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
897 let signing = SigningKey::random(&mut OsRng);
898 let verifying = p256::ecdsa::VerifyingKey::from(&signing);
899 let signed_headers_list = ["host", "x-amz-content-sha256", "x-amz-date", REGION_SET_HEADER];
900 let pre = Request::builder()
904 .method(method.clone())
905 .uri(path)
906 .header("host", "s3.example.com")
907 .header(
908 "x-amz-content-sha256",
909 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
910 )
911 .header("x-amz-date", "20260513T120000Z")
912 .header(REGION_SET_HEADER, region_set)
913 .body(())
914 .expect("pre-request");
915 let signed_headers: Vec<String> =
916 signed_headers_list.iter().map(|s| (*s).to_string()).collect();
917 let canonical = build_canonical_request_bytes(&pre, &signed_headers);
918 let sig: p256::ecdsa::Signature = signing.sign(&canonical);
919 let sig_hex = lower_hex(sig.to_der().as_bytes());
920 let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
921
922 let r = Request::builder()
926 .method(method)
927 .uri(path)
928 .header("host", "s3.example.com")
929 .header(
930 "x-amz-content-sha256",
931 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
932 )
933 .header("x-amz-date", "20260513T120000Z")
934 .header(REGION_SET_HEADER, region_set)
935 .header("authorization", auth)
936 .body(())
937 .expect("signed request");
938 (r, verifying)
939 }
940
941 fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
942 let mut m = HashMap::new();
943 m.insert(access_key.to_string(), vk);
944 let store = Arc::new(SigV4aCredentialStore::from_map(m));
945 Arc::new(SigV4aGate::new(store))
946 }
947
948 async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
950 resp.into_body()
951 .collect()
952 .await
953 .expect("body collect")
954 .to_bytes()
955 .to_vec()
956 }
957
958 #[test]
959 fn no_sigv4a_prefix_returns_none() {
960 let (_, vk) = (
962 (),
963 p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
964 );
965 let gate = make_gate_with("AKIAOK", vk);
966 let r = req(
967 Method::GET,
968 "/bucket/key",
969 &[(
970 "authorization",
971 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
972 SignedHeaders=host, Signature=deadbeef",
973 )],
974 );
975 assert!(
976 try_sigv4a_verify(&r, Some(&gate), "us-east-1").is_none(),
977 "plain SigV4 request must fall through to the inner service"
978 );
979 }
980
981 #[test]
982 fn sigv4a_valid_signature_returns_ok() {
983 let (r, vk) =
984 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
985 let gate = make_gate_with("AKIAOK", vk);
986 let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
987 .expect("must intercept SigV4a request");
988 assert!(
989 result.is_ok(),
990 "valid SigV4a signature must verify: {result:?}"
991 );
992 }
993
994 #[tokio::test]
995 async fn sigv4a_tampered_signature_returns_403() {
996 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
997 let gate = make_gate_with("AKIAOK", vk);
998
999 let auth = r
1004 .headers()
1005 .get("authorization")
1006 .and_then(|v| v.to_str().ok())
1007 .expect("auth header")
1008 .to_string();
1009 let mut chars: Vec<char> = auth.chars().collect();
1011 let last = chars.len() - 1;
1012 chars[last] = if chars[last] == '0' { '1' } else { '0' };
1013 let tampered_auth: String = chars.into_iter().collect();
1014 let tampered = req(
1015 Method::GET,
1016 "/bucket/key",
1017 &[
1018 ("host", "s3.example.com"),
1019 (
1020 "x-amz-content-sha256",
1021 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1022 ),
1023 ("x-amz-date", "20260513T120000Z"),
1024 (REGION_SET_HEADER, "us-east-1"),
1025 ("authorization", &tampered_auth),
1026 ],
1027 );
1028 let result = try_sigv4a_verify(&tampered, Some(&gate), "us-east-1")
1029 .expect("must intercept SigV4a request");
1030 let resp = result.expect_err("tampered signature must surface a 403 response");
1031 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1032 let body = body_to_bytes(resp).await;
1033 let body_str = String::from_utf8(body).expect("xml utf-8");
1034 assert!(
1035 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1036 "403 body must surface SignatureDoesNotMatch: {body_str}"
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn sigv4a_region_set_mismatch_returns_403() {
1042 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1048 let gate = make_gate_with("AKIAOK", vk);
1049 let result = try_sigv4a_verify(&r, Some(&gate), "eu-west-1")
1050 .expect("must intercept SigV4a request");
1051 let resp = result.expect_err("region mismatch must produce 403");
1052 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1053 let body = body_to_bytes(resp).await;
1054 let body_str = String::from_utf8(body).expect("xml utf-8");
1055 assert!(
1056 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1057 "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1058 );
1059 }
1060
1061 #[test]
1062 fn no_gate_attached_returns_none() {
1063 let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1068 assert!(
1069 try_sigv4a_verify(&r, None, "us-east-1").is_none(),
1070 "missing gate must defer to inner service"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn unknown_access_key_returns_403_invalid_access_key_id() {
1076 let (r, _vk_unused) =
1079 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1080 let other_signing = SigningKey::random(&mut OsRng);
1081 let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1082 let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1083 let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
1084 .expect("must intercept SigV4a request");
1085 let resp = result.expect_err("unknown key must produce 403");
1086 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1087 let body = body_to_bytes(resp).await;
1088 let body_str = String::from_utf8(body).expect("xml utf-8");
1089 assert!(
1090 body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1091 "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1092 );
1093 }
1094
1095 #[tokio::test]
1096 async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1097 let signing = SigningKey::random(&mut OsRng);
1102 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1103 let gate = make_gate_with("AKIAOK", vk);
1104 let r = req(
1105 Method::GET,
1106 "/bucket/key",
1107 &[
1108 (
1112 "authorization",
1113 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1114 SignedHeaders=host, Signature=deadbeef",
1115 ),
1116 (REGION_SET_HEADER, "us-east-1"),
1117 ],
1118 );
1119 let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
1120 .expect("must intercept SigV4a-shaped request");
1121 let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1122 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1123 let body = body_to_bytes(resp).await;
1124 let body_str = String::from_utf8(body).expect("xml utf-8");
1125 assert!(
1126 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1127 "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1128 );
1129 }
1130
1131 #[test]
1135 fn canonical_request_bytes_format() {
1136 let r = req(
1137 Method::PUT,
1138 "/bucket/key?z=1&a=2",
1139 &[
1140 ("host", "s3.example.com"),
1141 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1142 ("x-amz-date", " 20260513T120000Z "),
1143 ],
1144 );
1145 let signed: Vec<String> =
1146 ["host", "x-amz-content-sha256", "x-amz-date"].iter().map(|s| (*s).into()).collect();
1147 let bytes = build_canonical_request_bytes(&r, &signed);
1148 let s = std::str::from_utf8(&bytes).expect("utf-8");
1149 let expected = "PUT\n\
1150 /bucket/key\n\
1151 a=2&z=1\n\
1152 host:s3.example.com\n\
1153 x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1154 x-amz-date:20260513T120000Z\n\
1155 \n\
1156 host;x-amz-content-sha256;x-amz-date\n\
1157 UNSIGNED-PAYLOAD";
1158 assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1159 }
1160}