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 = build_canonical_request_bytes(req, &signed_headers);
332 match gate.pre_route_at(req, requested_region, &canonical, now) {
333 Ok(()) => Some(Ok(())),
334 Err(err) => {
335 tracing::warn!(error = %err, "SigV4a verify rejected request");
336 Some(Err(build_sigv4a_error_response(
337 err.http_status(),
338 err.s3_error_code(),
339 &err.to_string(),
340 )))
341 }
342 }
343}
344
345fn build_canonical_request_bytes<B>(req: &Request<B>, signed_headers: &[String]) -> Vec<u8> {
361 let mut buf = String::with_capacity(512);
362 buf.push_str(req.method().as_str());
363 buf.push('\n');
364 buf.push_str(req.uri().path());
365 buf.push('\n');
366 buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
367 buf.push('\n');
368 for name in signed_headers {
369 let value = req
370 .headers()
371 .get(name.as_str())
372 .and_then(|v| v.to_str().ok())
373 .unwrap_or("");
374 buf.push_str(name);
375 buf.push(':');
376 buf.push_str(&trim_collapse_ws(value));
380 buf.push('\n');
381 }
382 buf.push('\n');
383 buf.push_str(&signed_headers.join(";"));
384 buf.push('\n');
385 let payload_hash = req
386 .headers()
387 .get("x-amz-content-sha256")
388 .and_then(|v| v.to_str().ok())
389 .unwrap_or("UNSIGNED-PAYLOAD");
390 buf.push_str(payload_hash);
391 buf.into_bytes()
392}
393
394fn canonical_query_string(query: &str) -> String {
400 if query.is_empty() {
401 return String::new();
402 }
403 let mut pairs: Vec<(&str, &str)> = query
404 .split('&')
405 .filter(|s| !s.is_empty())
406 .map(|kv| match kv.split_once('=') {
407 Some((k, v)) => (k, v),
408 None => (kv, ""),
409 })
410 .collect();
411 pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
412 let mut out = String::with_capacity(query.len());
413 for (i, (k, v)) in pairs.iter().enumerate() {
414 if i > 0 {
415 out.push('&');
416 }
417 out.push_str(k);
418 out.push('=');
419 out.push_str(v);
420 }
421 out
422}
423
424fn trim_collapse_ws(s: &str) -> String {
430 let trimmed = s.trim();
431 let mut out = String::with_capacity(trimmed.len());
432 let mut prev_ws = false;
433 for c in trimmed.chars() {
434 if c.is_whitespace() {
435 if !prev_ws {
436 out.push(' ');
437 }
438 prev_ws = true;
439 } else {
440 out.push(c);
441 prev_ws = false;
442 }
443 }
444 out
445}
446
447fn build_sigv4a_error_response(
456 status: StatusCode,
457 code: &str,
458 message: &str,
459) -> Response<s3s::Body> {
460 let body_str = format!(
461 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
462 <Error>\n <Code>{code}</Code>\n <Message>{message}</Message>\n</Error>"
463 );
464 let bytes = Bytes::from(body_str.into_bytes());
465 Response::builder()
466 .status(status)
467 .header("content-type", "application/xml")
468 .header("content-length", bytes.len().to_string())
469 .body(s3s::Body::http_body(
470 Full::new(bytes).map_err(|never| match never {}),
471 ))
472 .expect("sigv4a error response builder")
473}
474
475type RespBody = s3s::Body;
479
480fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
481 let bytes = Bytes::from_static(body.as_bytes());
482 Response::builder()
483 .status(status)
484 .header("content-type", "text/plain; charset=utf-8")
485 .header("content-length", bytes.len().to_string())
486 .body(s3s::Body::http_body(
487 Full::new(bytes).map_err(|never| match never {}),
488 ))
489 .expect("static response")
490}
491
492fn make_owned_text_response(
493 status: StatusCode,
494 content_type: &'static str,
495 body: String,
496) -> Response<RespBody> {
497 let bytes = Bytes::from(body.into_bytes());
498 Response::builder()
499 .status(status)
500 .header("content-type", content_type)
501 .header("content-length", bytes.len().to_string())
502 .body(s3s::Body::http_body(
503 Full::new(bytes).map_err(|never| match never {}),
504 ))
505 .expect("owned response")
506}
507
508impl<S> Service<Request<Incoming>> for HealthRouter<S>
509where
510 S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
511 + Clone
512 + Send
513 + 'static,
514 S::Future: Send + 'static,
515{
516 type Response = Response<RespBody>;
517 type Error = s3s::HttpError;
518 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
519
520 fn call(&self, req: Request<Incoming>) -> Self::Future {
521 if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
527 return Box::pin(async move { Ok(resp) });
528 }
529 if let Some(result) = try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region) {
536 match result {
537 Ok(()) => {
538 }
541 Err(resp) => return Box::pin(async move { Ok(resp) }),
542 }
543 }
544 let path = req.uri().path();
545 match (req.method(), path) {
546 (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
547 Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
548 }
549 (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
550 let handle = self.metrics_handle.clone();
551 Box::pin(async move {
552 match handle {
553 Some(h) => {
554 let body = h.render();
555 Ok(make_owned_text_response(
556 StatusCode::OK,
557 "text/plain; version=0.0.4; charset=utf-8",
558 body,
559 ))
560 }
561 None => Ok(make_text_response(
562 StatusCode::SERVICE_UNAVAILABLE,
563 "metrics not configured\n",
564 )),
565 }
566 })
567 }
568 (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
569 let check = self.ready_check.clone();
570 Box::pin(async move {
571 match check {
572 Some(f) => match f().await {
573 Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
574 Err(reason) => {
575 tracing::warn!(%reason, "readiness check failed");
576 Ok(make_text_response(
577 StatusCode::SERVICE_UNAVAILABLE,
578 "not ready\n",
579 ))
580 }
581 },
582 None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
583 }
584 })
585 }
586 _ => {
587 let inner = self.inner.clone();
588 Box::pin(async move { inner.call(req).await })
589 }
590 }
591 }
592}
593
594trait FullExt<B> {
596 fn map_err<E, F: FnMut(Infallible) -> E>(
597 self,
598 f: F,
599 ) -> http_body_util::combinators::MapErr<Self, F>
600 where
601 Self: Sized;
602}
603impl<B> FullExt<B> for Full<B>
604where
605 B: bytes::Buf,
606{
607 fn map_err<E, F: FnMut(Infallible) -> E>(
608 self,
609 f: F,
610 ) -> http_body_util::combinators::MapErr<Self, F>
611 where
612 Self: Sized,
613 {
614 http_body_util::BodyExt::map_err(self, f)
615 }
616}
617
618#[cfg(test)]
619mod preflight_tests {
620 use super::*;
635 use crate::cors::{CorsConfig, CorsManager, CorsRule};
636
637 fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
638 CorsRule {
639 allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
640 allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
641 allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
642 expose_headers: vec!["ETag".into()],
643 max_age_seconds: Some(600),
644 id: Some("test".into()),
645 }
646 }
647
648 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
651 let mut b = Request::builder().method(method).uri(path);
652 for (k, v) in headers {
653 b = b.header(*k, *v);
654 }
655 b.body(()).expect("request builder")
656 }
657
658 fn manager_with_rule() -> Arc<CorsManager> {
659 let mgr = CorsManager::new();
660 mgr.put(
661 "b",
662 CorsConfig {
663 rules: vec![rule(
664 &["https://app.example.com"],
665 &["GET", "PUT", "DELETE"],
666 &["Content-Type", "X-Amz-Date"],
667 )],
668 },
669 );
670 Arc::new(mgr)
671 }
672
673 #[test]
674 fn preflight_match_returns_allow_response() {
675 let mgr = manager_with_rule();
676 let r = req(
677 Method::OPTIONS,
678 "/b/key.txt",
679 &[
680 ("origin", "https://app.example.com"),
681 ("access-control-request-method", "PUT"),
682 ("access-control-request-headers", "content-type, x-amz-date"),
683 ],
684 );
685 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
686 assert_eq!(resp.status(), StatusCode::OK);
687 let h = resp.headers();
688 assert_eq!(
689 h.get("access-control-allow-origin")
690 .and_then(|v| v.to_str().ok()),
691 Some("https://app.example.com")
692 );
693 assert_eq!(
694 h.get("access-control-allow-methods")
695 .and_then(|v| v.to_str().ok()),
696 Some("GET, PUT, DELETE")
697 );
698 assert_eq!(
699 h.get("access-control-allow-headers")
700 .and_then(|v| v.to_str().ok()),
701 Some("Content-Type, X-Amz-Date")
702 );
703 assert_eq!(
704 h.get("access-control-max-age")
705 .and_then(|v| v.to_str().ok()),
706 Some("600")
707 );
708 assert_eq!(
709 h.get("access-control-expose-headers")
710 .and_then(|v| v.to_str().ok()),
711 Some("ETag")
712 );
713 }
714
715 #[test]
716 fn preflight_no_match_returns_403() {
717 let mgr = manager_with_rule();
718 let r = req(
722 Method::OPTIONS,
723 "/b/key.txt",
724 &[
725 ("origin", "https://evil.example.com"),
726 ("access-control-request-method", "PUT"),
727 ],
728 );
729 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
730 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
731 assert!(resp.headers().get("access-control-allow-origin").is_none());
733 }
734
735 #[test]
736 fn preflight_no_origin_falls_through() {
737 let mgr = manager_with_rule();
740 let r = req(
741 Method::OPTIONS,
742 "/b/key.txt",
743 &[("access-control-request-method", "PUT")],
744 );
745 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
746 }
747
748 #[test]
749 fn non_options_falls_through() {
750 let mgr = manager_with_rule();
751 let r = req(
753 Method::GET,
754 "/b/key.txt",
755 &[
756 ("origin", "https://app.example.com"),
757 ("access-control-request-method", "PUT"),
758 ],
759 );
760 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
761 }
762
763 #[test]
764 fn no_cors_config_for_bucket_falls_through() {
765 let mgr = manager_with_rule();
768 let r = req(
769 Method::OPTIONS,
770 "/ghost/key.txt",
771 &[
772 ("origin", "https://app.example.com"),
773 ("access-control-request-method", "PUT"),
774 ],
775 );
776 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
777 }
778
779 #[test]
780 fn no_manager_attached_falls_through() {
781 let r = req(
782 Method::OPTIONS,
783 "/b/key.txt",
784 &[
785 ("origin", "https://app.example.com"),
786 ("access-control-request-method", "PUT"),
787 ],
788 );
789 assert!(try_handle_preflight(&r, None).is_none());
790 }
791
792 #[test]
793 fn preflight_wildcard_origin_echoes_star() {
794 let mgr = CorsManager::new();
796 mgr.put(
797 "b",
798 CorsConfig {
799 rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
800 },
801 );
802 let mgr = Arc::new(mgr);
803 let r = req(
804 Method::OPTIONS,
805 "/b/key",
806 &[
807 ("origin", "https://anywhere.example"),
808 ("access-control-request-method", "PUT"),
809 ("access-control-request-headers", "x-custom-header"),
810 ],
811 );
812 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
813 assert_eq!(resp.status(), StatusCode::OK);
814 assert_eq!(
815 resp.headers()
816 .get("access-control-allow-origin")
817 .and_then(|v| v.to_str().ok()),
818 Some("*"),
819 "wildcard rule must echo literal '*' instead of requesting origin"
820 );
821 }
822
823 #[test]
824 fn preflight_empty_path_falls_through() {
825 let mgr = manager_with_rule();
826 let r = req(
827 Method::OPTIONS,
828 "/",
829 &[
830 ("origin", "https://app.example.com"),
831 ("access-control-request-method", "PUT"),
832 ],
833 );
834 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
835 }
836}
837
838#[cfg(test)]
839mod sigv4a_gate_tests {
840 use super::*;
868
869 use std::collections::HashMap;
870
871 use http_body_util::BodyExt;
872 use p256::ecdsa::SigningKey;
873 use p256::ecdsa::signature::Signer;
874 use rand::rngs::OsRng;
875
876 use crate::service::SigV4aGate;
877 use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
878
879 fn lower_hex(bytes: &[u8]) -> String {
880 let mut s = String::with_capacity(bytes.len() * 2);
881 for b in bytes {
882 s.push_str(&format!("{b:02x}"));
883 }
884 s
885 }
886
887 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
889 let mut b = Request::builder().method(method).uri(path);
890 for (k, v) in headers {
891 b = b.header(*k, *v);
892 }
893 b.body(()).expect("request builder")
894 }
895
896 fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
899 format!(
900 "AWS4-ECDSA-P256-SHA256 \
901 Credential={access_key}/20260513/s3/aws4_request, \
902 SignedHeaders={}, \
903 Signature={sig_hex}",
904 signed_headers.join(";")
905 )
906 }
907
908 fn make_signed_request(
912 access_key: &str,
913 method: Method,
914 path: &str,
915 region_set: &str,
916 ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
917 let signing = SigningKey::random(&mut OsRng);
918 let verifying = p256::ecdsa::VerifyingKey::from(&signing);
919 let signed_headers_list = [
920 "host",
921 "x-amz-content-sha256",
922 "x-amz-date",
923 REGION_SET_HEADER,
924 ];
925 let pre = Request::builder()
929 .method(method.clone())
930 .uri(path)
931 .header("host", "s3.example.com")
932 .header(
933 "x-amz-content-sha256",
934 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
935 )
936 .header("x-amz-date", "20260513T120000Z")
937 .header(REGION_SET_HEADER, region_set)
938 .body(())
939 .expect("pre-request");
940 let signed_headers: Vec<String> = signed_headers_list
941 .iter()
942 .map(|s| (*s).to_string())
943 .collect();
944 let canonical = build_canonical_request_bytes(&pre, &signed_headers);
945 let sig: p256::ecdsa::Signature = signing.sign(&canonical);
946 let sig_hex = lower_hex(sig.to_der().as_bytes());
947 let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
948
949 let r = Request::builder()
953 .method(method)
954 .uri(path)
955 .header("host", "s3.example.com")
956 .header(
957 "x-amz-content-sha256",
958 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
959 )
960 .header("x-amz-date", "20260513T120000Z")
961 .header(REGION_SET_HEADER, region_set)
962 .header("authorization", auth)
963 .body(())
964 .expect("signed request");
965 (r, verifying)
966 }
967
968 fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
969 let mut m = HashMap::new();
970 m.insert(access_key.to_string(), vk);
971 let store = Arc::new(SigV4aCredentialStore::from_map(m));
972 Arc::new(SigV4aGate::new(store))
973 }
974
975 async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
977 resp.into_body()
978 .collect()
979 .await
980 .expect("body collect")
981 .to_bytes()
982 .to_vec()
983 }
984
985 fn fixture_now() -> chrono::DateTime<chrono::Utc> {
991 chrono::DateTime::parse_from_rfc3339("2026-05-13T12:00:00Z")
992 .unwrap()
993 .with_timezone(&chrono::Utc)
994 }
995
996 #[test]
997 fn no_sigv4a_prefix_returns_none() {
998 let (_, vk) = (
1000 (),
1001 p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
1002 );
1003 let gate = make_gate_with("AKIAOK", vk);
1004 let r = req(
1005 Method::GET,
1006 "/bucket/key",
1007 &[(
1008 "authorization",
1009 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1010 SignedHeaders=host, Signature=deadbeef",
1011 )],
1012 );
1013 assert!(
1014 try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now()).is_none(),
1015 "plain SigV4 request must fall through to the inner service"
1016 );
1017 }
1018
1019 #[test]
1020 fn sigv4a_valid_signature_returns_ok() {
1021 let (r, vk) =
1022 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
1023 let gate = make_gate_with("AKIAOK", vk);
1024 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1025 .expect("must intercept SigV4a request");
1026 assert!(
1027 result.is_ok(),
1028 "valid SigV4a signature must verify: {result:?}"
1029 );
1030 }
1031
1032 #[tokio::test]
1033 async fn sigv4a_tampered_signature_returns_403() {
1034 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1035 let gate = make_gate_with("AKIAOK", vk);
1036
1037 let auth = r
1042 .headers()
1043 .get("authorization")
1044 .and_then(|v| v.to_str().ok())
1045 .expect("auth header")
1046 .to_string();
1047 let mut chars: Vec<char> = auth.chars().collect();
1049 let last = chars.len() - 1;
1050 chars[last] = if chars[last] == '0' { '1' } else { '0' };
1051 let tampered_auth: String = chars.into_iter().collect();
1052 let tampered = req(
1053 Method::GET,
1054 "/bucket/key",
1055 &[
1056 ("host", "s3.example.com"),
1057 (
1058 "x-amz-content-sha256",
1059 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1060 ),
1061 ("x-amz-date", "20260513T120000Z"),
1062 (REGION_SET_HEADER, "us-east-1"),
1063 ("authorization", &tampered_auth),
1064 ],
1065 );
1066 let result = try_sigv4a_verify_at(&tampered, Some(&gate), "us-east-1", fixture_now())
1067 .expect("must intercept SigV4a request");
1068 let resp = result.expect_err("tampered signature must surface a 403 response");
1069 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1070 let body = body_to_bytes(resp).await;
1071 let body_str = String::from_utf8(body).expect("xml utf-8");
1072 assert!(
1073 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1074 "403 body must surface SignatureDoesNotMatch: {body_str}"
1075 );
1076 }
1077
1078 #[tokio::test]
1079 async fn sigv4a_region_set_mismatch_returns_403() {
1080 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1086 let gate = make_gate_with("AKIAOK", vk);
1087 let result = try_sigv4a_verify_at(&r, Some(&gate), "eu-west-1", fixture_now())
1088 .expect("must intercept SigV4a request");
1089 let resp = result.expect_err("region mismatch must produce 403");
1090 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1091 let body = body_to_bytes(resp).await;
1092 let body_str = String::from_utf8(body).expect("xml utf-8");
1093 assert!(
1094 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1095 "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1096 );
1097 }
1098
1099 #[test]
1100 fn no_gate_attached_returns_none() {
1101 let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1106 assert!(
1107 try_sigv4a_verify_at(&r, None, "us-east-1", fixture_now()).is_none(),
1108 "missing gate must defer to inner service"
1109 );
1110 }
1111
1112 #[tokio::test]
1113 async fn unknown_access_key_returns_403_invalid_access_key_id() {
1114 let (r, _vk_unused) =
1117 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1118 let other_signing = SigningKey::random(&mut OsRng);
1119 let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1120 let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1121 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1122 .expect("must intercept SigV4a request");
1123 let resp = result.expect_err("unknown key must produce 403");
1124 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1125 let body = body_to_bytes(resp).await;
1126 let body_str = String::from_utf8(body).expect("xml utf-8");
1127 assert!(
1128 body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1129 "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1135 let signing = SigningKey::random(&mut OsRng);
1140 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1141 let gate = make_gate_with("AKIAOK", vk);
1142 let r = req(
1143 Method::GET,
1144 "/bucket/key",
1145 &[
1146 (
1150 "authorization",
1151 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1152 SignedHeaders=host, Signature=deadbeef",
1153 ),
1154 (REGION_SET_HEADER, "us-east-1"),
1155 ],
1156 );
1157 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1158 .expect("must intercept SigV4a-shaped request");
1159 let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1160 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1161 let body = body_to_bytes(resp).await;
1162 let body_str = String::from_utf8(body).expect("xml utf-8");
1163 assert!(
1164 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1165 "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1166 );
1167 }
1168
1169 #[tokio::test]
1176 async fn sigv4a_replay_outside_window_returns_403_request_time_too_skewed() {
1177 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1178 let gate = make_gate_with("AKIAOK", vk);
1179 let now = chrono::DateTime::parse_from_rfc3339("2026-05-13T12:30:00Z")
1182 .unwrap()
1183 .with_timezone(&chrono::Utc);
1184 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", now)
1185 .expect("must intercept SigV4a request");
1186 let resp = result.expect_err("replay outside window must reject");
1187 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1188 let body = body_to_bytes(resp).await;
1189 let body_str = String::from_utf8(body).expect("xml utf-8");
1190 assert!(
1191 body_str.contains("<Code>RequestTimeTooSkewed</Code>"),
1192 "replay outside window must surface RequestTimeTooSkewed: {body_str}"
1193 );
1194 }
1195
1196 #[test]
1200 fn canonical_request_bytes_format() {
1201 let r = req(
1202 Method::PUT,
1203 "/bucket/key?z=1&a=2",
1204 &[
1205 ("host", "s3.example.com"),
1206 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1207 ("x-amz-date", " 20260513T120000Z "),
1208 ],
1209 );
1210 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1211 .iter()
1212 .map(|s| (*s).into())
1213 .collect();
1214 let bytes = build_canonical_request_bytes(&r, &signed);
1215 let s = std::str::from_utf8(&bytes).expect("utf-8");
1216 let expected = "PUT\n\
1217 /bucket/key\n\
1218 a=2&z=1\n\
1219 host:s3.example.com\n\
1220 x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1221 x-amz-date:20260513T120000Z\n\
1222 \n\
1223 host;x-amz-content-sha256;x-amz-date\n\
1224 UNSIGNED-PAYLOAD";
1225 assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1226 }
1227}