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 try_sigv4a_verify_at(req, gate, requested_region, chrono::Utc::now())
284}
285
286pub fn try_sigv4a_verify_at<B>(
291 req: &Request<B>,
292 gate: Option<&Arc<SigV4aGate>>,
293 requested_region: &str,
294 now: chrono::DateTime<chrono::Utc>,
295) -> Option<Result<(), Response<s3s::Body>>> {
296 let gate = gate?;
297 if !crate::sigv4a::detect(req) {
298 return None;
300 }
301 let auth_hdr = req
314 .headers()
315 .get(http::header::AUTHORIZATION)
316 .and_then(|v| v.to_str().ok());
317 let signed_headers: Vec<String> =
318 match auth_hdr.and_then(|hdr| crate::sigv4a::parse_authorization_header(hdr).ok()) {
319 Some(parsed) => parsed.signed_headers,
320 None => {
321 return Some(Err(build_sigv4a_error_response(
325 StatusCode::FORBIDDEN,
326 "SignatureDoesNotMatch",
327 "missing or malformed Authorization header for SigV4a request",
328 )));
329 }
330 };
331 let canonical = match build_canonical_request_bytes(req, &signed_headers) {
332 Ok(bytes) => bytes,
333 Err(err) => {
334 tracing::warn!(error = %err, "SigV4a canonical-request build rejected request");
340 return Some(Err(build_sigv4a_error_response(
341 StatusCode::FORBIDDEN,
342 "SignatureDoesNotMatch",
343 &err.to_string(),
344 )));
345 }
346 };
347 match gate.pre_route_at(req, requested_region, &canonical, now) {
348 Ok(()) => Some(Ok(())),
349 Err(err) => {
350 tracing::warn!(error = %err, "SigV4a verify rejected request");
351 Some(Err(build_sigv4a_error_response(
352 err.http_status(),
353 err.s3_error_code(),
354 &err.to_string(),
355 )))
356 }
357 }
358}
359
360fn build_canonical_request_bytes<B>(
388 req: &Request<B>,
389 signed_headers: &[String],
390) -> Result<Vec<u8>, crate::sigv4a::SigV4aError> {
391 let mut buf = String::with_capacity(512);
392 buf.push_str(req.method().as_str());
393 buf.push('\n');
394 buf.push_str(req.uri().path());
395 buf.push('\n');
396 buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
397 buf.push('\n');
398 for name in signed_headers {
399 let occurrences = req.headers().get_all(name.as_str()).iter().count();
410 if occurrences > 1 {
411 return Err(crate::sigv4a::SigV4aError::DuplicateSignedHeader {
412 header: name.clone(),
413 });
414 }
415 let value = req
416 .headers()
417 .get(name.as_str())
418 .and_then(|v| v.to_str().ok())
419 .unwrap_or("");
420 buf.push_str(name);
421 buf.push(':');
422 buf.push_str(&trim_collapse_ws(value));
426 buf.push('\n');
427 }
428 buf.push('\n');
429 buf.push_str(&signed_headers.join(";"));
430 buf.push('\n');
431 let payload_hash = req
432 .headers()
433 .get("x-amz-content-sha256")
434 .and_then(|v| v.to_str().ok())
435 .unwrap_or("UNSIGNED-PAYLOAD");
436 buf.push_str(payload_hash);
437 Ok(buf.into_bytes())
438}
439
440fn canonical_query_string(query: &str) -> String {
446 if query.is_empty() {
447 return String::new();
448 }
449 let mut pairs: Vec<(&str, &str)> = query
450 .split('&')
451 .filter(|s| !s.is_empty())
452 .map(|kv| match kv.split_once('=') {
453 Some((k, v)) => (k, v),
454 None => (kv, ""),
455 })
456 .collect();
457 pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
458 let mut out = String::with_capacity(query.len());
459 for (i, (k, v)) in pairs.iter().enumerate() {
460 if i > 0 {
461 out.push('&');
462 }
463 out.push_str(k);
464 out.push('=');
465 out.push_str(v);
466 }
467 out
468}
469
470fn trim_collapse_ws(s: &str) -> String {
476 let trimmed = s.trim();
477 let mut out = String::with_capacity(trimmed.len());
478 let mut prev_ws = false;
479 for c in trimmed.chars() {
480 if c.is_whitespace() {
481 if !prev_ws {
482 out.push(' ');
483 }
484 prev_ws = true;
485 } else {
486 out.push(c);
487 prev_ws = false;
488 }
489 }
490 out
491}
492
493fn build_sigv4a_error_response(
502 status: StatusCode,
503 code: &str,
504 message: &str,
505) -> Response<s3s::Body> {
506 let body_str = format!(
507 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
508 <Error>\n <Code>{code}</Code>\n <Message>{message}</Message>\n</Error>"
509 );
510 let bytes = Bytes::from(body_str.into_bytes());
511 Response::builder()
512 .status(status)
513 .header("content-type", "application/xml")
514 .header("content-length", bytes.len().to_string())
515 .body(s3s::Body::http_body(
516 Full::new(bytes).map_err(|never| match never {}),
517 ))
518 .expect("sigv4a error response builder")
519}
520
521type RespBody = s3s::Body;
525
526fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
527 let bytes = Bytes::from_static(body.as_bytes());
528 Response::builder()
529 .status(status)
530 .header("content-type", "text/plain; charset=utf-8")
531 .header("content-length", bytes.len().to_string())
532 .body(s3s::Body::http_body(
533 Full::new(bytes).map_err(|never| match never {}),
534 ))
535 .expect("static response")
536}
537
538fn make_owned_text_response(
539 status: StatusCode,
540 content_type: &'static str,
541 body: String,
542) -> Response<RespBody> {
543 let bytes = Bytes::from(body.into_bytes());
544 Response::builder()
545 .status(status)
546 .header("content-type", content_type)
547 .header("content-length", bytes.len().to_string())
548 .body(s3s::Body::http_body(
549 Full::new(bytes).map_err(|never| match never {}),
550 ))
551 .expect("owned response")
552}
553
554impl<S> Service<Request<Incoming>> for HealthRouter<S>
555where
556 S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
557 + Clone
558 + Send
559 + 'static,
560 S::Future: Send + 'static,
561{
562 type Response = Response<RespBody>;
563 type Error = s3s::HttpError;
564 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
565
566 fn call(&self, req: Request<Incoming>) -> Self::Future {
567 if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
573 return Box::pin(async move { Ok(resp) });
574 }
575 if let Some(result) = try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region) {
582 match result {
583 Ok(()) => {
584 }
587 Err(resp) => return Box::pin(async move { Ok(resp) }),
588 }
589 }
590 let path = req.uri().path();
591 match (req.method(), path) {
592 (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
593 Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
594 }
595 (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
596 let handle = self.metrics_handle.clone();
597 Box::pin(async move {
598 match handle {
599 Some(h) => {
600 let body = h.render();
601 Ok(make_owned_text_response(
602 StatusCode::OK,
603 "text/plain; version=0.0.4; charset=utf-8",
604 body,
605 ))
606 }
607 None => Ok(make_text_response(
608 StatusCode::SERVICE_UNAVAILABLE,
609 "metrics not configured\n",
610 )),
611 }
612 })
613 }
614 (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
615 let check = self.ready_check.clone();
616 Box::pin(async move {
617 match check {
618 Some(f) => match f().await {
619 Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
620 Err(reason) => {
621 tracing::warn!(%reason, "readiness check failed");
622 Ok(make_text_response(
623 StatusCode::SERVICE_UNAVAILABLE,
624 "not ready\n",
625 ))
626 }
627 },
628 None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
629 }
630 })
631 }
632 _ => {
633 let inner = self.inner.clone();
634 Box::pin(async move { inner.call(req).await })
635 }
636 }
637 }
638}
639
640trait FullExt<B> {
642 fn map_err<E, F: FnMut(Infallible) -> E>(
643 self,
644 f: F,
645 ) -> http_body_util::combinators::MapErr<Self, F>
646 where
647 Self: Sized;
648}
649impl<B> FullExt<B> for Full<B>
650where
651 B: bytes::Buf,
652{
653 fn map_err<E, F: FnMut(Infallible) -> E>(
654 self,
655 f: F,
656 ) -> http_body_util::combinators::MapErr<Self, F>
657 where
658 Self: Sized,
659 {
660 http_body_util::BodyExt::map_err(self, f)
661 }
662}
663
664#[cfg(test)]
665mod preflight_tests {
666 use super::*;
681 use crate::cors::{CorsConfig, CorsManager, CorsRule};
682
683 fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
684 CorsRule {
685 allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
686 allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
687 allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
688 expose_headers: vec!["ETag".into()],
689 max_age_seconds: Some(600),
690 id: Some("test".into()),
691 }
692 }
693
694 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
697 let mut b = Request::builder().method(method).uri(path);
698 for (k, v) in headers {
699 b = b.header(*k, *v);
700 }
701 b.body(()).expect("request builder")
702 }
703
704 fn manager_with_rule() -> Arc<CorsManager> {
705 let mgr = CorsManager::new();
706 mgr.put(
707 "b",
708 CorsConfig {
709 rules: vec![rule(
710 &["https://app.example.com"],
711 &["GET", "PUT", "DELETE"],
712 &["Content-Type", "X-Amz-Date"],
713 )],
714 },
715 );
716 Arc::new(mgr)
717 }
718
719 #[test]
720 fn preflight_match_returns_allow_response() {
721 let mgr = manager_with_rule();
722 let r = req(
723 Method::OPTIONS,
724 "/b/key.txt",
725 &[
726 ("origin", "https://app.example.com"),
727 ("access-control-request-method", "PUT"),
728 ("access-control-request-headers", "content-type, x-amz-date"),
729 ],
730 );
731 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
732 assert_eq!(resp.status(), StatusCode::OK);
733 let h = resp.headers();
734 assert_eq!(
735 h.get("access-control-allow-origin")
736 .and_then(|v| v.to_str().ok()),
737 Some("https://app.example.com")
738 );
739 assert_eq!(
740 h.get("access-control-allow-methods")
741 .and_then(|v| v.to_str().ok()),
742 Some("GET, PUT, DELETE")
743 );
744 assert_eq!(
745 h.get("access-control-allow-headers")
746 .and_then(|v| v.to_str().ok()),
747 Some("Content-Type, X-Amz-Date")
748 );
749 assert_eq!(
750 h.get("access-control-max-age")
751 .and_then(|v| v.to_str().ok()),
752 Some("600")
753 );
754 assert_eq!(
755 h.get("access-control-expose-headers")
756 .and_then(|v| v.to_str().ok()),
757 Some("ETag")
758 );
759 }
760
761 #[test]
762 fn preflight_no_match_returns_403() {
763 let mgr = manager_with_rule();
764 let r = req(
768 Method::OPTIONS,
769 "/b/key.txt",
770 &[
771 ("origin", "https://evil.example.com"),
772 ("access-control-request-method", "PUT"),
773 ],
774 );
775 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
776 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
777 assert!(resp.headers().get("access-control-allow-origin").is_none());
779 }
780
781 #[test]
782 fn preflight_no_origin_falls_through() {
783 let mgr = manager_with_rule();
786 let r = req(
787 Method::OPTIONS,
788 "/b/key.txt",
789 &[("access-control-request-method", "PUT")],
790 );
791 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
792 }
793
794 #[test]
795 fn non_options_falls_through() {
796 let mgr = manager_with_rule();
797 let r = req(
799 Method::GET,
800 "/b/key.txt",
801 &[
802 ("origin", "https://app.example.com"),
803 ("access-control-request-method", "PUT"),
804 ],
805 );
806 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
807 }
808
809 #[test]
810 fn no_cors_config_for_bucket_falls_through() {
811 let mgr = manager_with_rule();
814 let r = req(
815 Method::OPTIONS,
816 "/ghost/key.txt",
817 &[
818 ("origin", "https://app.example.com"),
819 ("access-control-request-method", "PUT"),
820 ],
821 );
822 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
823 }
824
825 #[test]
826 fn no_manager_attached_falls_through() {
827 let r = req(
828 Method::OPTIONS,
829 "/b/key.txt",
830 &[
831 ("origin", "https://app.example.com"),
832 ("access-control-request-method", "PUT"),
833 ],
834 );
835 assert!(try_handle_preflight(&r, None).is_none());
836 }
837
838 #[test]
839 fn preflight_wildcard_origin_echoes_star() {
840 let mgr = CorsManager::new();
842 mgr.put(
843 "b",
844 CorsConfig {
845 rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
846 },
847 );
848 let mgr = Arc::new(mgr);
849 let r = req(
850 Method::OPTIONS,
851 "/b/key",
852 &[
853 ("origin", "https://anywhere.example"),
854 ("access-control-request-method", "PUT"),
855 ("access-control-request-headers", "x-custom-header"),
856 ],
857 );
858 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
859 assert_eq!(resp.status(), StatusCode::OK);
860 assert_eq!(
861 resp.headers()
862 .get("access-control-allow-origin")
863 .and_then(|v| v.to_str().ok()),
864 Some("*"),
865 "wildcard rule must echo literal '*' instead of requesting origin"
866 );
867 }
868
869 #[test]
870 fn preflight_empty_path_falls_through() {
871 let mgr = manager_with_rule();
872 let r = req(
873 Method::OPTIONS,
874 "/",
875 &[
876 ("origin", "https://app.example.com"),
877 ("access-control-request-method", "PUT"),
878 ],
879 );
880 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
881 }
882}
883
884#[cfg(test)]
885mod sigv4a_gate_tests {
886 use super::*;
914
915 use std::collections::HashMap;
916
917 use http_body_util::BodyExt;
918 use p256::ecdsa::SigningKey;
919 use p256::ecdsa::signature::Signer;
920 use rand::rngs::OsRng;
921
922 use crate::service::SigV4aGate;
923 use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
924
925 fn lower_hex(bytes: &[u8]) -> String {
926 let mut s = String::with_capacity(bytes.len() * 2);
927 for b in bytes {
928 s.push_str(&format!("{b:02x}"));
929 }
930 s
931 }
932
933 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
935 let mut b = Request::builder().method(method).uri(path);
936 for (k, v) in headers {
937 b = b.header(*k, *v);
938 }
939 b.body(()).expect("request builder")
940 }
941
942 fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
945 format!(
946 "AWS4-ECDSA-P256-SHA256 \
947 Credential={access_key}/20260513/s3/aws4_request, \
948 SignedHeaders={}, \
949 Signature={sig_hex}",
950 signed_headers.join(";")
951 )
952 }
953
954 fn make_signed_request(
958 access_key: &str,
959 method: Method,
960 path: &str,
961 region_set: &str,
962 ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
963 let signing = SigningKey::random(&mut OsRng);
964 let verifying = p256::ecdsa::VerifyingKey::from(&signing);
965 let signed_headers_list = [
966 "host",
967 "x-amz-content-sha256",
968 "x-amz-date",
969 REGION_SET_HEADER,
970 ];
971 let pre = Request::builder()
975 .method(method.clone())
976 .uri(path)
977 .header("host", "s3.example.com")
978 .header(
979 "x-amz-content-sha256",
980 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
981 )
982 .header("x-amz-date", "20260513T120000Z")
983 .header(REGION_SET_HEADER, region_set)
984 .body(())
985 .expect("pre-request");
986 let signed_headers: Vec<String> = signed_headers_list
987 .iter()
988 .map(|s| (*s).to_string())
989 .collect();
990 let canonical =
991 build_canonical_request_bytes(&pre, &signed_headers).expect("test fixture canonical");
992 let canonical_hash = {
997 use sha2::{Digest, Sha256};
998 let mut h = Sha256::new();
999 h.update(&canonical);
1000 let out = h.finalize();
1001 let mut s = String::with_capacity(out.len() * 2);
1002 for b in out {
1003 use std::fmt::Write as _;
1004 let _ = write!(s, "{b:02x}");
1005 }
1006 s
1007 };
1008 let sts = format!(
1009 "AWS4-ECDSA-P256-SHA256\n20260513T120000Z\n20260513/s3/aws4_request\n{canonical_hash}"
1010 );
1011 let sig: p256::ecdsa::Signature = signing.sign(sts.as_bytes());
1012 let sig_hex = lower_hex(sig.to_der().as_bytes());
1013 let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
1014
1015 let r = Request::builder()
1019 .method(method)
1020 .uri(path)
1021 .header("host", "s3.example.com")
1022 .header(
1023 "x-amz-content-sha256",
1024 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1025 )
1026 .header("x-amz-date", "20260513T120000Z")
1027 .header(REGION_SET_HEADER, region_set)
1028 .header("authorization", auth)
1029 .body(())
1030 .expect("signed request");
1031 (r, verifying)
1032 }
1033
1034 fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
1035 let mut m = HashMap::new();
1036 m.insert(access_key.to_string(), vk);
1037 let store = Arc::new(SigV4aCredentialStore::from_map(m));
1038 Arc::new(SigV4aGate::new(store))
1039 }
1040
1041 async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
1043 resp.into_body()
1044 .collect()
1045 .await
1046 .expect("body collect")
1047 .to_bytes()
1048 .to_vec()
1049 }
1050
1051 fn fixture_now() -> chrono::DateTime<chrono::Utc> {
1057 chrono::DateTime::parse_from_rfc3339("2026-05-13T12:00:00Z")
1058 .unwrap()
1059 .with_timezone(&chrono::Utc)
1060 }
1061
1062 #[test]
1063 fn no_sigv4a_prefix_returns_none() {
1064 let (_, vk) = (
1066 (),
1067 p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
1068 );
1069 let gate = make_gate_with("AKIAOK", vk);
1070 let r = req(
1071 Method::GET,
1072 "/bucket/key",
1073 &[(
1074 "authorization",
1075 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1076 SignedHeaders=host, Signature=deadbeef",
1077 )],
1078 );
1079 assert!(
1080 try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now()).is_none(),
1081 "plain SigV4 request must fall through to the inner service"
1082 );
1083 }
1084
1085 #[test]
1086 fn sigv4a_valid_signature_returns_ok() {
1087 let (r, vk) =
1088 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
1089 let gate = make_gate_with("AKIAOK", vk);
1090 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1091 .expect("must intercept SigV4a request");
1092 assert!(
1093 result.is_ok(),
1094 "valid SigV4a signature must verify: {result:?}"
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn sigv4a_tampered_signature_returns_403() {
1100 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1101 let gate = make_gate_with("AKIAOK", vk);
1102
1103 let auth = r
1108 .headers()
1109 .get("authorization")
1110 .and_then(|v| v.to_str().ok())
1111 .expect("auth header")
1112 .to_string();
1113 let mut chars: Vec<char> = auth.chars().collect();
1115 let last = chars.len() - 1;
1116 chars[last] = if chars[last] == '0' { '1' } else { '0' };
1117 let tampered_auth: String = chars.into_iter().collect();
1118 let tampered = req(
1119 Method::GET,
1120 "/bucket/key",
1121 &[
1122 ("host", "s3.example.com"),
1123 (
1124 "x-amz-content-sha256",
1125 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1126 ),
1127 ("x-amz-date", "20260513T120000Z"),
1128 (REGION_SET_HEADER, "us-east-1"),
1129 ("authorization", &tampered_auth),
1130 ],
1131 );
1132 let result = try_sigv4a_verify_at(&tampered, Some(&gate), "us-east-1", fixture_now())
1133 .expect("must intercept SigV4a request");
1134 let resp = result.expect_err("tampered signature must surface a 403 response");
1135 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1136 let body = body_to_bytes(resp).await;
1137 let body_str = String::from_utf8(body).expect("xml utf-8");
1138 assert!(
1139 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1140 "403 body must surface SignatureDoesNotMatch: {body_str}"
1141 );
1142 }
1143
1144 #[tokio::test]
1145 async fn sigv4a_region_set_mismatch_returns_403() {
1146 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1152 let gate = make_gate_with("AKIAOK", vk);
1153 let result = try_sigv4a_verify_at(&r, Some(&gate), "eu-west-1", fixture_now())
1154 .expect("must intercept SigV4a request");
1155 let resp = result.expect_err("region mismatch must produce 403");
1156 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1157 let body = body_to_bytes(resp).await;
1158 let body_str = String::from_utf8(body).expect("xml utf-8");
1159 assert!(
1160 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1161 "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1162 );
1163 }
1164
1165 #[test]
1166 fn no_gate_attached_returns_none() {
1167 let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1172 assert!(
1173 try_sigv4a_verify_at(&r, None, "us-east-1", fixture_now()).is_none(),
1174 "missing gate must defer to inner service"
1175 );
1176 }
1177
1178 #[tokio::test]
1179 async fn unknown_access_key_returns_403_invalid_access_key_id() {
1180 let (r, _vk_unused) =
1183 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1184 let other_signing = SigningKey::random(&mut OsRng);
1185 let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1186 let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1187 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1188 .expect("must intercept SigV4a request");
1189 let resp = result.expect_err("unknown key must produce 403");
1190 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1191 let body = body_to_bytes(resp).await;
1192 let body_str = String::from_utf8(body).expect("xml utf-8");
1193 assert!(
1194 body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1195 "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1196 );
1197 }
1198
1199 #[tokio::test]
1200 async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1201 let signing = SigningKey::random(&mut OsRng);
1206 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1207 let gate = make_gate_with("AKIAOK", vk);
1208 let r = req(
1209 Method::GET,
1210 "/bucket/key",
1211 &[
1212 (
1216 "authorization",
1217 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1218 SignedHeaders=host, Signature=deadbeef",
1219 ),
1220 (REGION_SET_HEADER, "us-east-1"),
1221 ],
1222 );
1223 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1224 .expect("must intercept SigV4a-shaped request");
1225 let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1226 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1227 let body = body_to_bytes(resp).await;
1228 let body_str = String::from_utf8(body).expect("xml utf-8");
1229 assert!(
1230 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1231 "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1232 );
1233 }
1234
1235 #[tokio::test]
1242 async fn sigv4a_replay_outside_window_returns_403_request_time_too_skewed() {
1243 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1244 let gate = make_gate_with("AKIAOK", vk);
1245 let now = chrono::DateTime::parse_from_rfc3339("2026-05-13T12:30:00Z")
1248 .unwrap()
1249 .with_timezone(&chrono::Utc);
1250 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", now)
1251 .expect("must intercept SigV4a request");
1252 let resp = result.expect_err("replay outside window must reject");
1253 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1254 let body = body_to_bytes(resp).await;
1255 let body_str = String::from_utf8(body).expect("xml utf-8");
1256 assert!(
1257 body_str.contains("<Code>RequestTimeTooSkewed</Code>"),
1258 "replay outside window must surface RequestTimeTooSkewed: {body_str}"
1259 );
1260 }
1261
1262 #[test]
1266 fn canonical_request_bytes_format() {
1267 let r = req(
1268 Method::PUT,
1269 "/bucket/key?z=1&a=2",
1270 &[
1271 ("host", "s3.example.com"),
1272 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1273 ("x-amz-date", " 20260513T120000Z "),
1274 ],
1275 );
1276 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1277 .iter()
1278 .map(|s| (*s).into())
1279 .collect();
1280 let bytes =
1281 build_canonical_request_bytes(&r, &signed).expect("canonical request bytes must build");
1282 let s = std::str::from_utf8(&bytes).expect("utf-8");
1283 let expected = "PUT\n\
1284 /bucket/key\n\
1285 a=2&z=1\n\
1286 host:s3.example.com\n\
1287 x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1288 x-amz-date:20260513T120000Z\n\
1289 \n\
1290 host;x-amz-content-sha256;x-amz-date\n\
1291 UNSIGNED-PAYLOAD";
1292 assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1293 }
1294
1295 #[test]
1302 fn sigv4a_duplicate_x_amz_date_rejected() {
1303 let r = Request::builder()
1307 .method(Method::GET)
1308 .uri("/b/k")
1309 .header("host", "s3.example.com")
1310 .header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
1311 .header("x-amz-date", "20260513T120000Z")
1312 .header("x-amz-date", "20260513T130000Z")
1313 .body(())
1314 .expect("dup-header request");
1315 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1316 .iter()
1317 .map(|s| (*s).into())
1318 .collect();
1319 let err = build_canonical_request_bytes(&r, &signed)
1320 .expect_err("duplicate x-amz-date must reject");
1321 match err {
1322 crate::sigv4a::SigV4aError::DuplicateSignedHeader { header } => {
1323 assert_eq!(header, "x-amz-date");
1324 }
1325 other => panic!("expected DuplicateSignedHeader, got {other:?}"),
1326 }
1327 }
1328
1329 #[test]
1334 fn sigv4a_canonicalization_single_header_passes() {
1335 let r = req(
1336 Method::GET,
1337 "/b/k",
1338 &[
1339 ("host", "s3.example.com"),
1340 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1341 ("x-amz-date", "20260513T120000Z"),
1342 ],
1343 );
1344 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1345 .iter()
1346 .map(|s| (*s).into())
1347 .collect();
1348 let bytes =
1349 build_canonical_request_bytes(&r, &signed).expect("single-occurrence must accept");
1350 let s = std::str::from_utf8(&bytes).expect("utf-8");
1354 assert!(
1355 s.contains("x-amz-date:20260513T120000Z"),
1356 "canonical bytes must echo the single x-amz-date verbatim:\n{s}"
1357 );
1358 }
1359
1360 #[tokio::test]
1365 async fn sigv4a_pre_route_rejects_duplicate_signed_header() {
1366 let signing = SigningKey::random(&mut OsRng);
1367 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1368 let gate = make_gate_with("AKIAOK", vk);
1369 let auth = build_auth_header(
1373 "AKIAOK",
1374 &[
1375 "host",
1376 "x-amz-content-sha256",
1377 "x-amz-date",
1378 REGION_SET_HEADER,
1379 ],
1380 "deadbeef",
1381 );
1382 let r = Request::builder()
1383 .method(Method::GET)
1384 .uri("/bucket/key")
1385 .header("host", "s3.example.com")
1386 .header(
1387 "x-amz-content-sha256",
1388 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1389 )
1390 .header("x-amz-date", "20260513T120000Z")
1391 .header("x-amz-date", "20260513T130000Z")
1392 .header(REGION_SET_HEADER, "us-east-1")
1393 .header("authorization", auth)
1394 .body(())
1395 .expect("dup-header sigv4a request");
1396 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1397 .expect("must intercept SigV4a request");
1398 let resp = result.expect_err("duplicate signed header must reject at the gate");
1399 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1400 let body = body_to_bytes(resp).await;
1401 let body_str = String::from_utf8(body).expect("xml utf-8");
1402 assert!(
1403 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1404 "duplicate signed header must surface SignatureDoesNotMatch: {body_str}"
1405 );
1406 assert!(
1407 body_str.contains("duplicate signed header"),
1408 "diagnostic must mention duplicate header: {body_str}"
1409 );
1410 }
1411}