s4_server/routing.rs
1//! `/health` と `/ready` の HTTP routing layer + CORS OPTIONS preflight
2//! interceptor + SigV4a verify gate。
3//!
4//! S3 server と同じポートで health probe に応答できると AWS ALB / NLB / k8s
5//! readiness probe との統合が単純になる。
6//!
7//! - `GET /health` → 常に `200 OK` (server プロセスが生きていれば返す)
8//! - `GET /ready` → `ready_check` future を await し、`Ok(())` なら 200、
9//! それ以外 (backend 不通等) は 503。
10//! - `OPTIONS /<bucket>[/<key>]` (Origin + Access-Control-Request-Method 付き)
11//! → v0.7 #44: `cors_manager` が attach されていれば、bucket の登録された
12//! rule list に対して preflight match を実行し、200 + Allow-* header を
13//! 組み立てて返す (no match なら 403)。s3s framework は OPTIONS verb を
14//! typed handler として持たないため、HTTP-level の interceptor で寄せる。
15//! - `Authorization: AWS4-ECDSA-P256-SHA256 ...` (SigV4a) を持つ request
16//! → v0.7 #47: `sigv4a_gate` が attach されていれば、listener 側で署名を
17//! verify し、success なら inner S3Service へ forward、failure なら 403
18//! `SignatureDoesNotMatch` / `InvalidAccessKeyId` を直接返す。s3s 既存の
19//! SigV4 verifier は `AWS4-ECDSA-P256-SHA256` を "unknown algorithm" として
20//! reject するため、middleware を挟まないと SigV4a request は届かない。
21//! - その他のパス → inner S3Service へ委譲
22
23use 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
38/// readiness check 関数。bound is `Send + Sync` for cross-task use.
39pub type ReadyCheck =
40 Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
41
42/// inner service と health/ready/metrics + CORS preflight handler +
43/// SigV4a verify gate を合成する hyper Service。
44#[derive(Clone)]
45pub struct HealthRouter<S> {
46 pub inner: S,
47 pub ready_check: Option<ReadyCheck>,
48 pub metrics_handle: Option<PrometheusHandle>,
49 /// v0.7 #44: optional CORS bucket-config manager. When attached,
50 /// OPTIONS requests carrying `Origin` + `Access-Control-Request-Method`
51 /// are intercepted before reaching the s3s service and answered
52 /// directly with Access-Control-Allow-* headers (or 403 if no rule
53 /// matches). When `None`, OPTIONS falls through to the inner service
54 /// (s3s typically returns 405 since no S3 handler maps to OPTIONS).
55 pub cors_manager: Option<Arc<CorsManager>>,
56 /// v0.7 #47: optional SigV4a verify gate. When attached, requests
57 /// whose `Authorization` header begins with `AWS4-ECDSA-P256-SHA256`
58 /// (or that carry `X-Amz-Region-Set`) are verified at the HTTP
59 /// layer using the configured ECDSA-P-256 credential store; on
60 /// failure the listener returns 403 directly. When `None`, the
61 /// gate is a no-op so plain SigV4 deployments are unaffected.
62 pub sigv4a_gate: Option<Arc<SigV4aGate>>,
63 /// v0.7 #47: region name used when checking
64 /// `X-Amz-Region-Set` membership during SigV4a verification. The
65 /// listener is single-region in this milestone — operators that
66 /// front S4 with a Multi-Region Access Point set this to the
67 /// canonical "this listener's region" string. Defaults to
68 /// `"us-east-1"` (the AWS-default region when none is configured).
69 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 /// v0.7 #44: attach an `Arc<CorsManager>` so OPTIONS preflight
91 /// requests are handled at the HTTP layer instead of falling through
92 /// to s3s.
93 #[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 /// v0.7 #47: attach an `Arc<SigV4aGate>` so `AWS4-ECDSA-P256-SHA256`
100 /// requests are verified at the HTTP layer instead of being
101 /// rejected by s3s' SigV4 verifier as "unknown algorithm".
102 #[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 /// v0.7 #47: override the listener's "served region" string used
109 /// to check `X-Amz-Region-Set` membership during SigV4a
110 /// verification. Defaults to `"us-east-1"`.
111 #[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/// v0.7 #44: HTTP-level OPTIONS preflight interceptor.
119///
120/// Returns:
121/// - `Some(response)` if `req` is an OPTIONS preflight (Origin +
122/// Access-Control-Request-Method headers present) targeting a bucket
123/// with CORS configured. The response is 200 with Allow-* headers
124/// when a rule matches, or 403 when no rule matches the
125/// (origin, method, headers) triple.
126/// - `None` if the request is not a preflight, or no CORS config is
127/// registered for the target bucket — caller forwards to the s3s
128/// service.
129///
130/// `cors` is `Option<&Arc<CorsManager>>` so callers can pass through
131/// the inner service's optional manager without unwrapping first.
132///
133/// Generic over the request body type `B` so unit tests can drive the
134/// matcher with `Request<()>` without constructing a real `Incoming`
135/// stream (only headers, method, and URI are inspected).
136#[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 // Path is `/<bucket>` or `/<bucket>/<key>` — first segment is bucket.
146 // Empty path or a query-only request has no bucket and is not a
147 // preflight we can answer.
148 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 // Access-Control-Request-Headers is a comma-separated list, optional
160 // (browsers omit it when no custom headers are being sent).
161 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 // No config for this bucket → not our problem (let s3s handle / 404).
173 // We need to distinguish "no config" from "config but no rule matches"
174 // to correctly fall through vs. return 403.
175 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
182/// 200 response with the matched rule's Allow-* headers.
183fn build_preflight_allow_response(rule: &CorsRule, origin: &str) -> Response<s3s::Body> {
184 let mut builder = Response::builder().status(StatusCode::OK);
185 // Echo the matched origin: literal "*" if the rule used a wildcard,
186 // otherwise the requesting origin verbatim (S3 spec).
187 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 // Empty body, but set content-length explicitly for clarity.
213 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
222/// 403 response when an OPTIONS preflight reaches a bucket with CORS
223/// configured but no rule matches the (origin, method, headers) triple.
224fn 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
236// ===========================================================================
237// v0.7 #47 — SigV4a verify gate middleware.
238// ===========================================================================
239
240/// v0.7 #47: Try to verify the request as SigV4a-signed.
241///
242/// Returns:
243/// - `None` if the request is not SigV4a-signed (no `AWS4-ECDSA-P256-SHA256`
244/// `Authorization` prefix and no `X-Amz-Region-Set` header) — the
245/// caller forwards the request to s3s for the default SigV4 path.
246/// - `Some(Ok(()))` if SigV4a verify succeeded — the caller forwards to
247/// the inner service so the S3 handler runs.
248/// - `Some(Err(response))` if SigV4a verify failed — the caller returns
249/// the 403 response directly without ever invoking the inner service.
250///
251/// `gate` is `Option<&Arc<SigV4aGate>>` so callers can pass through the
252/// router's optional gate without unwrapping first; when `None`, this
253/// function always returns `None` (no SigV4a verification configured).
254///
255/// `requested_region` is the listener's served region (used to validate
256/// the request's `X-Amz-Region-Set` header membership).
257///
258/// Generic over the request body type `B` so unit tests can drive the
259/// matcher with `Request<()>` without constructing a real `Incoming`
260/// stream — only headers, method, and URI participate in the canonical
261/// request bytes built here.
262///
263/// # Canonical request bytes
264///
265/// We build a SigV4-shaped canonical request from the HTTP-layer
266/// signal alone (method, URI path, sorted query string, headers in the
267/// order listed by `SignedHeaders=`, and `x-amz-content-sha256` as the
268/// payload hash — the standard "client-supplied body hash" convention
269/// every AWS SDK uses). Reading the body would force a `Request<Bytes>`
270/// rebuild and break the s3s framework's streaming-body assumptions, so
271/// the payload-hash header is the only correct source for SigV4a.
272///
273/// Clients that want to sign over the body must include the actual
274/// SHA-256 of the body in `x-amz-content-sha256`; clients that don't
275/// (most S3 SDKs default to `UNSIGNED-PAYLOAD` for streaming PUTs) sign
276/// over that literal string instead. Either way the bytes the gate
277/// compares against are exactly what the client computed.
278pub 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
286/// v0.8.4 #76: like [`try_sigv4a_verify`] but takes an explicit `now`
287/// for tests that need to pin the freshness clock without time-warping
288/// the system clock. Production callers always reach this via
289/// `try_sigv4a_verify` (which calls `chrono::Utc::now()`).
290pub 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 // Not a SigV4a request — caller forwards to the SigV4 path.
299 return None;
300 }
301 // Pre-parse the Authorization header so we know which signed-headers
302 // list to canonicalise in. If the header is malformed, fail fast
303 // with 403 rather than building canonical bytes that can never
304 // verify.
305 //
306 // v0.8.4 #76: `parse_authorization_header` now returns `Result`
307 // (was `Option`) so the gate can surface scope-shape failures
308 // (`InvalidCredentialScope`, `WrongService`, etc.) as 400
309 // InvalidRequest. Any non-Ok parse falls through to the
310 // SignatureDoesNotMatch 403 the original code returned, since at
311 // this point we can't extract a `signed_headers` list to feed the
312 // canonical-request builder.
313 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 // No / unparseable Authorization header but `detect` flagged
322 // it as SigV4a-shaped (e.g. only the region-set header is
323 // present) — surface as SignatureDoesNotMatch directly.
324 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 // v0.8.5 #84 H-4: duplicate signed header (only failure
335 // mode the canonical builder has today). Surface as
336 // `SignatureDoesNotMatch` 403 — the AWS SDKs treat that
337 // as the catch-all auth-failure code, and the diagnostic
338 // is in the response body / server log.
339 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
360/// v0.7 #47: build a SigV4-shaped canonical request from the HTTP
361/// surface alone (no body access). Returns the bytes that the
362/// SigV4a gate will check the ECDSA signature against.
363///
364/// Format (one element per line, joined with `\n`):
365/// 1. HTTP method (uppercase)
366/// 2. canonical URI (path; we leave it untouched since AWS SDKs
367/// pre-encode it the same way s3s receives it)
368/// 3. canonical query string (sorted by name, name=value pairs joined
369/// by `&`; empty when no query string)
370/// 4. canonical headers (one `name:trimmed-value\n` per signed header,
371/// in the **order** they appear in `SignedHeaders=`)
372/// 5. signed headers list (lowercase names joined by `;`)
373/// 6. payload hash (value of `x-amz-content-sha256`, or `UNSIGNED-PAYLOAD`
374/// if absent)
375///
376/// v0.8.5 #84 (audit H-4): every signed header is checked for being
377/// sent **exactly once** on the request. If a header in
378/// `SignedHeaders=` appears more than once we'd have to choose between
379/// the first value (`HeaderMap::get` semantics) and the comma-joined
380/// AWS-canonical form — and any S3 SDK / WAF / sidecar in front of us
381/// would make a different choice, opening "auth confusion" attacks
382/// (sign over the benign first `x-amz-date`, smuggle a second one for
383/// the inner parser). HTTP/1.1 spec already forbids duplicates of
384/// `host` / `x-amz-date` and the AWS SDKs never emit them, so any
385/// duplicate is a malicious or broken request — reject upfront with
386/// [`SigV4aError::DuplicateSignedHeader`].
387fn 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 // v0.8.15 H-d: canonical URI per RFC 3986 unreserved set. Real
395 // AWS SDKs decode + re-encode (uppercase hex, only unreserved
396 // chars left literal) before hashing, so receiving the same
397 // request through a normalising TLS terminator that lowercases
398 // `%2f` to `%2F` (or vice versa) would otherwise produce a
399 // different canonical form than what the SDK signed. `/`
400 // path-segment separators stay literal — S3 doesn't escape them
401 // in the canonical path.
402 buf.push_str(&canonical_uri_path(req.uri().path()));
403 buf.push('\n');
404 buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
405 buf.push('\n');
406 for name in signed_headers {
407 // v0.8.5 #84 H-4: count occurrences via `get_all` rather than
408 // `get`, which only ever returns the first value. Two
409 // `x-amz-date` headers with `get` would canonicalise to the
410 // first value while a downstream HTTP/1.1 parser might pick
411 // the second — auth confusion. Single-value reject is the
412 // safe choice; comma-join would be the AWS-canonical form
413 // for legitimately multi-valued signed headers, but the AWS
414 // SDKs never sign over comma-joined values for any header
415 // S3 cares about, so refusing duplicates outright matches
416 // every real-world client.
417 let occurrences = req.headers().get_all(name.as_str()).iter().count();
418 if occurrences > 1 {
419 return Err(crate::sigv4a::SigV4aError::DuplicateSignedHeader {
420 header: name.clone(),
421 });
422 }
423 let value = req
424 .headers()
425 .get(name.as_str())
426 .and_then(|v| v.to_str().ok())
427 .unwrap_or("");
428 buf.push_str(name);
429 buf.push(':');
430 // Trim whitespace and collapse repeated inner whitespace per
431 // SigV4 canonicalisation rules. This is the same trimming AWS
432 // SDKs do when they sign.
433 buf.push_str(&trim_collapse_ws(value));
434 buf.push('\n');
435 }
436 buf.push('\n');
437 buf.push_str(&signed_headers.join(";"));
438 buf.push('\n');
439 let payload_hash = req
440 .headers()
441 .get("x-amz-content-sha256")
442 .and_then(|v| v.to_str().ok())
443 .unwrap_or("UNSIGNED-PAYLOAD");
444 buf.push_str(payload_hash);
445 Ok(buf.into_bytes())
446}
447
448/// SigV4 canonical query string: split on `&`, parse each `k=v` (or
449/// `k`), sort lexicographically by name (then by value), re-join with
450/// `&`. Empty input → empty string. We do **not** re-encode the values
451/// — they already arrived URL-encoded over the wire, and AWS SDKs
452/// expect the server to compare the bytes verbatim.
453fn canonical_query_string(query: &str) -> String {
454 if query.is_empty() {
455 return String::new();
456 }
457 // v0.8.15 H-d: AWS SigV4 / SigV4a spec — decode each key/value to
458 // raw bytes, then re-encode with the AWS canonical form (RFC
459 // 3986 unreserved set, uppercase hex), then sort by the encoded
460 // key (and value as tiebreaker). The pre-H-d code took the raw
461 // wire bytes and sorted those, which produced a different
462 // canonical string than the SDK's output for any of these
463 // mismatches:
464 //
465 // 1. Lowercase `%2f` in the wire vs. SDK-canonical uppercase
466 // `%2F` (some TLS terminators normalise).
467 // 2. Mixed encoding choices (one side encodes `=` as `%3D`, the
468 // other leaves it bare).
469 // 3. Sort order on raw bytes vs. encoded bytes differs when one
470 // side encodes a char the other left literal.
471 //
472 // Real AWS SDKs always emit fully-encoded canonical form, so the
473 // pre-H-d "verbatim sort" only matched signatures the gate itself
474 // produced, not signatures real clients ship.
475 let mut pairs: Vec<(String, String)> = query
476 .split('&')
477 .filter(|s| !s.is_empty())
478 .map(|kv| match kv.split_once('=') {
479 Some((k, v)) => (percent_decode_to_string(k), percent_decode_to_string(v)),
480 None => (percent_decode_to_string(kv), String::new()),
481 })
482 .map(|(k, v)| (aws_canonical_encode(&k), aws_canonical_encode(&v)))
483 .collect();
484 pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
485 let mut out = String::with_capacity(query.len());
486 for (i, (k, v)) in pairs.iter().enumerate() {
487 if i > 0 {
488 out.push('&');
489 }
490 out.push_str(k);
491 out.push('=');
492 out.push_str(v);
493 }
494 out
495}
496
497/// v0.8.15 H-d: AWS canonical URI path encoding. Pulls each segment
498/// out of the slash-separated path, decodes any percent-encoded
499/// bytes, then re-encodes with the canonical form. Slashes are
500/// preserved literal (S3 doesn't escape segment separators in the
501/// canonical path).
502fn canonical_uri_path(path: &str) -> String {
503 if path.is_empty() {
504 return "/".to_owned();
505 }
506 let mut out = String::with_capacity(path.len());
507 let mut first = true;
508 for segment in path.split('/') {
509 if !first {
510 out.push('/');
511 }
512 first = false;
513 let decoded = percent_decode_to_string(segment);
514 out.push_str(&aws_canonical_encode(&decoded));
515 }
516 out
517}
518
519/// v0.8.15 H-d: decode a percent-encoded UTF-8 string to its raw
520/// bytes, then interpret as `String` (lossy fallback on bad UTF-8 so
521/// we never panic on weird input). The `percent_encoding` crate
522/// gives us the decode iterator; we just collect.
523fn percent_decode_to_string(s: &str) -> String {
524 percent_encoding::percent_decode_str(s)
525 .decode_utf8_lossy()
526 .into_owned()
527}
528
529/// v0.8.15 H-d: encode a UTF-8 string per AWS SigV4 canonical form.
530/// Per RFC 3986 the unreserved set is `A-Z a-z 0-9 - _ . ~`; every
531/// other byte becomes `%XX` with uppercase hex. AWS treats this set
532/// identically (it's the AWS-canonical set).
533fn aws_canonical_encode(s: &str) -> String {
534 /// AWS canonical set per SigV4 spec — equivalent to RFC 3986
535 /// unreserved. Everything else gets `%XX`.
536 const AWS_CANONICAL_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
537 .remove(b'-')
538 .remove(b'_')
539 .remove(b'.')
540 .remove(b'~');
541 percent_encoding::utf8_percent_encode(s, AWS_CANONICAL_SET).to_string()
542}
543
544/// SigV4 header-value canonicalisation: trim leading + trailing
545/// whitespace and collapse runs of internal whitespace to a single
546/// space. This mirrors what AWS SDKs do client-side when computing the
547/// canonical request — without it, a header value with extra spaces
548/// would canonicalise differently on each side.
549fn trim_collapse_ws(s: &str) -> String {
550 let trimmed = s.trim();
551 let mut out = String::with_capacity(trimmed.len());
552 let mut prev_ws = false;
553 for c in trimmed.chars() {
554 if c.is_whitespace() {
555 if !prev_ws {
556 out.push(' ');
557 }
558 prev_ws = true;
559 } else {
560 out.push(c);
561 prev_ws = false;
562 }
563 }
564 out
565}
566
567/// v0.7 #47: build an AWS-shaped XML response for a SigV4a verify
568/// failure. The response body matches the wire format AWS S3 emits for
569/// the same conditions so SDKs surface the right exception class to the
570/// caller.
571///
572/// v0.8.4 #76: now takes `status` so the gate can return 400
573/// InvalidRequest for malformed-input failures (missing x-amz-date,
574/// wrong service scope, etc.) and 403 for actual auth failures.
575fn build_sigv4a_error_response(
576 status: StatusCode,
577 code: &str,
578 message: &str,
579) -> Response<s3s::Body> {
580 let body_str = format!(
581 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
582 <Error>\n <Code>{code}</Code>\n <Message>{message}</Message>\n</Error>"
583 );
584 let bytes = Bytes::from(body_str.into_bytes());
585 Response::builder()
586 .status(status)
587 .header("content-type", "application/xml")
588 .header("content-length", bytes.len().to_string())
589 .body(s3s::Body::http_body(
590 Full::new(bytes).map_err(|never| match never {}),
591 ))
592 .expect("sigv4a error response builder")
593}
594
595/// `/health` と `/ready` のレスポンス Body。
596/// inner S3Service の Body と互換する形にするために `s3s::Body` でラップ可能な
597/// `Full<Bytes>` を `s3s::Body::http_body` 経由で構築する。
598type RespBody = s3s::Body;
599
600fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
601 let bytes = Bytes::from_static(body.as_bytes());
602 Response::builder()
603 .status(status)
604 .header("content-type", "text/plain; charset=utf-8")
605 .header("content-length", bytes.len().to_string())
606 .body(s3s::Body::http_body(
607 Full::new(bytes).map_err(|never| match never {}),
608 ))
609 .expect("static response")
610}
611
612fn make_owned_text_response(
613 status: StatusCode,
614 content_type: &'static str,
615 body: String,
616) -> Response<RespBody> {
617 let bytes = Bytes::from(body.into_bytes());
618 Response::builder()
619 .status(status)
620 .header("content-type", content_type)
621 .header("content-length", bytes.len().to_string())
622 .body(s3s::Body::http_body(
623 Full::new(bytes).map_err(|never| match never {}),
624 ))
625 .expect("owned response")
626}
627
628impl<S> Service<Request<Incoming>> for HealthRouter<S>
629where
630 S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
631 + Clone
632 + Send
633 + 'static,
634 S::Future: Send + 'static,
635{
636 type Response = Response<RespBody>;
637 type Error = s3s::HttpError;
638 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
639
640 fn call(&self, req: Request<Incoming>) -> Self::Future {
641 // v0.7 #44: short-circuit CORS OPTIONS preflight at the HTTP layer
642 // before health/metrics dispatch. Preflight must run only for
643 // OPTIONS requests, and only when a CORS manager is attached and
644 // a config exists for the requested bucket; otherwise fall
645 // through to the existing routing logic.
646 if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
647 return Box::pin(async move { Ok(resp) });
648 }
649 // v0.7 #47: SigV4a verify gate. When the request is signed with
650 // `AWS4-ECDSA-P256-SHA256` and a credential store is configured,
651 // verify here at the HTTP layer (s3s' SigV4 verifier would
652 // otherwise reject the request as "unknown algorithm" before
653 // any handler ran). Plain SigV4 (HMAC) requests return `None`
654 // and fall through to the inner service untouched.
655 if let Some(result) = try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region) {
656 match result {
657 Ok(()) => {
658 // verified — fall through to the path-routing logic
659 // below (the health/metrics/inner-service dispatch).
660 }
661 Err(resp) => return Box::pin(async move { Ok(resp) }),
662 }
663 }
664 let path = req.uri().path();
665 match (req.method(), path) {
666 (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
667 Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
668 }
669 (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
670 let handle = self.metrics_handle.clone();
671 Box::pin(async move {
672 match handle {
673 Some(h) => {
674 let body = h.render();
675 Ok(make_owned_text_response(
676 StatusCode::OK,
677 "text/plain; version=0.0.4; charset=utf-8",
678 body,
679 ))
680 }
681 None => Ok(make_text_response(
682 StatusCode::SERVICE_UNAVAILABLE,
683 "metrics not configured\n",
684 )),
685 }
686 })
687 }
688 (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
689 let check = self.ready_check.clone();
690 Box::pin(async move {
691 match check {
692 Some(f) => match f().await {
693 Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
694 Err(reason) => {
695 tracing::warn!(%reason, "readiness check failed");
696 Ok(make_text_response(
697 StatusCode::SERVICE_UNAVAILABLE,
698 "not ready\n",
699 ))
700 }
701 },
702 None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
703 }
704 })
705 }
706 _ => {
707 let inner = self.inner.clone();
708 Box::pin(async move { inner.call(req).await })
709 }
710 }
711 }
712}
713
714/// `Infallible` を anything に変換するためのトリック (`Full::map_err` 用)
715trait FullExt<B> {
716 fn map_err<E, F: FnMut(Infallible) -> E>(
717 self,
718 f: F,
719 ) -> http_body_util::combinators::MapErr<Self, F>
720 where
721 Self: Sized;
722}
723impl<B> FullExt<B> for Full<B>
724where
725 B: bytes::Buf,
726{
727 fn map_err<E, F: FnMut(Infallible) -> E>(
728 self,
729 f: F,
730 ) -> http_body_util::combinators::MapErr<Self, F>
731 where
732 Self: Sized,
733 {
734 http_body_util::BodyExt::map_err(self, f)
735 }
736}
737
738#[cfg(test)]
739mod preflight_tests {
740 //! v0.7 #44: unit tests for the OPTIONS preflight interceptor.
741 //!
742 //! These exercise [`try_handle_preflight`] directly — no hyper
743 //! `Incoming` body is needed because the function is generic over
744 //! the body type. Behavioural matrix:
745 //!
746 //! 1. matching preflight → 200 + Allow-* headers
747 //! 2. no matching rule (config exists, but origin/method/headers fail)
748 //! → 403
749 //! 3. missing `Origin` header → `None` (not a CORS preflight)
750 //! 4. non-OPTIONS verb → `None`
751 //! 5. no CORS config registered for the bucket → `None`
752 //! 6. no manager attached → `None`
753
754 use super::*;
755 use crate::cors::{CorsConfig, CorsManager, CorsRule};
756
757 fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
758 CorsRule {
759 allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
760 allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
761 allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
762 expose_headers: vec!["ETag".into()],
763 max_age_seconds: Some(600),
764 id: Some("test".into()),
765 }
766 }
767
768 /// Helper: build a `Request<()>` with the given method, path, and
769 /// headers — body is ignored by the matcher.
770 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
771 let mut b = Request::builder().method(method).uri(path);
772 for (k, v) in headers {
773 b = b.header(*k, *v);
774 }
775 b.body(()).expect("request builder")
776 }
777
778 fn manager_with_rule() -> Arc<CorsManager> {
779 let mgr = CorsManager::new();
780 mgr.put(
781 "b",
782 CorsConfig {
783 rules: vec![rule(
784 &["https://app.example.com"],
785 &["GET", "PUT", "DELETE"],
786 &["Content-Type", "X-Amz-Date"],
787 )],
788 },
789 );
790 Arc::new(mgr)
791 }
792
793 #[test]
794 fn preflight_match_returns_allow_response() {
795 let mgr = manager_with_rule();
796 let r = req(
797 Method::OPTIONS,
798 "/b/key.txt",
799 &[
800 ("origin", "https://app.example.com"),
801 ("access-control-request-method", "PUT"),
802 ("access-control-request-headers", "content-type, x-amz-date"),
803 ],
804 );
805 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
806 assert_eq!(resp.status(), StatusCode::OK);
807 let h = resp.headers();
808 assert_eq!(
809 h.get("access-control-allow-origin")
810 .and_then(|v| v.to_str().ok()),
811 Some("https://app.example.com")
812 );
813 assert_eq!(
814 h.get("access-control-allow-methods")
815 .and_then(|v| v.to_str().ok()),
816 Some("GET, PUT, DELETE")
817 );
818 assert_eq!(
819 h.get("access-control-allow-headers")
820 .and_then(|v| v.to_str().ok()),
821 Some("Content-Type, X-Amz-Date")
822 );
823 assert_eq!(
824 h.get("access-control-max-age")
825 .and_then(|v| v.to_str().ok()),
826 Some("600")
827 );
828 assert_eq!(
829 h.get("access-control-expose-headers")
830 .and_then(|v| v.to_str().ok()),
831 Some("ETag")
832 );
833 }
834
835 #[test]
836 fn preflight_no_match_returns_403() {
837 let mgr = manager_with_rule();
838 // Origin not in allow-list → no rule matches but bucket has CORS
839 // config, so we must answer 403 directly (not fall through to
840 // s3s, which would otherwise leak the bucket existence via 405).
841 let r = req(
842 Method::OPTIONS,
843 "/b/key.txt",
844 &[
845 ("origin", "https://evil.example.com"),
846 ("access-control-request-method", "PUT"),
847 ],
848 );
849 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
850 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
851 // 403 deny response must NOT carry Allow-Origin (RFC 7234 + S3 wire compat).
852 assert!(resp.headers().get("access-control-allow-origin").is_none());
853 }
854
855 #[test]
856 fn preflight_no_origin_falls_through() {
857 // OPTIONS without Origin is a generic OPTIONS (e.g. `OPTIONS *`)
858 // — not a CORS preflight, must not be intercepted.
859 let mgr = manager_with_rule();
860 let r = req(
861 Method::OPTIONS,
862 "/b/key.txt",
863 &[("access-control-request-method", "PUT")],
864 );
865 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
866 }
867
868 #[test]
869 fn non_options_falls_through() {
870 let mgr = manager_with_rule();
871 // Even with Origin + ACRM headers, GET is not a preflight.
872 let r = req(
873 Method::GET,
874 "/b/key.txt",
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 #[test]
884 fn no_cors_config_for_bucket_falls_through() {
885 // Manager attached but no rule registered for "ghost" → fall
886 // through to inner service so backend can respond naturally.
887 let mgr = manager_with_rule();
888 let r = req(
889 Method::OPTIONS,
890 "/ghost/key.txt",
891 &[
892 ("origin", "https://app.example.com"),
893 ("access-control-request-method", "PUT"),
894 ],
895 );
896 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
897 }
898
899 #[test]
900 fn no_manager_attached_falls_through() {
901 let r = req(
902 Method::OPTIONS,
903 "/b/key.txt",
904 &[
905 ("origin", "https://app.example.com"),
906 ("access-control-request-method", "PUT"),
907 ],
908 );
909 assert!(try_handle_preflight(&r, None).is_none());
910 }
911
912 #[test]
913 fn preflight_wildcard_origin_echoes_star() {
914 // Rule with `*` origin → response echoes literal "*" (S3 spec).
915 let mgr = CorsManager::new();
916 mgr.put(
917 "b",
918 CorsConfig {
919 rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
920 },
921 );
922 let mgr = Arc::new(mgr);
923 let r = req(
924 Method::OPTIONS,
925 "/b/key",
926 &[
927 ("origin", "https://anywhere.example"),
928 ("access-control-request-method", "PUT"),
929 ("access-control-request-headers", "x-custom-header"),
930 ],
931 );
932 let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
933 assert_eq!(resp.status(), StatusCode::OK);
934 assert_eq!(
935 resp.headers()
936 .get("access-control-allow-origin")
937 .and_then(|v| v.to_str().ok()),
938 Some("*"),
939 "wildcard rule must echo literal '*' instead of requesting origin"
940 );
941 }
942
943 #[test]
944 fn preflight_empty_path_falls_through() {
945 let mgr = manager_with_rule();
946 let r = req(
947 Method::OPTIONS,
948 "/",
949 &[
950 ("origin", "https://app.example.com"),
951 ("access-control-request-method", "PUT"),
952 ],
953 );
954 assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
955 }
956}
957
958#[cfg(test)]
959mod sigv4a_gate_tests {
960 //! v0.7 #47: unit tests for the SigV4a verify gate middleware.
961 //!
962 //! These exercise [`try_sigv4a_verify`] directly — no hyper
963 //! `Incoming` body is needed because the function is generic over
964 //! the body type. The canonical-request bytes computed by the
965 //! middleware are the same bytes the test signs over (we use the
966 //! `build_canonical_request_bytes` helper for both sides), so the
967 //! happy-path verify is end-to-end byte-exact.
968 //!
969 //! Behavioural matrix:
970 //!
971 //! 1. no `AWS4-ECDSA-P256-SHA256` prefix and no region-set header
972 //! → `None` (caller forwards to s3s SigV4 path)
973 //! 2. SigV4a Authorization + valid signature → `Some(Ok(()))`
974 //! 3. SigV4a Authorization + tampered signature → `Some(Err(403))`
975 //! with `SignatureDoesNotMatch` body
976 //! 4. SigV4a Authorization + region-set mismatch → `Some(Err(403))`
977 //! 5. gate is `None` (no credential store) → `None` even when the
978 //! request looks SigV4a-shaped (caller forwards, and s3s will
979 //! surface its own "unknown algorithm" error — operator sees the
980 //! misconfiguration rather than a silent pass)
981 //! 6. unknown access-key-id → `Some(Err(403))` with
982 //! `InvalidAccessKeyId` body
983 //! 7. SigV4a-shaped (region-set header only, no SigV4a auth header)
984 //! → `Some(Err(403))` (we cannot verify without a parseable
985 //! Authorization, fail closed)
986
987 use super::*;
988
989 use std::collections::HashMap;
990
991 use http_body_util::BodyExt;
992 use p256::ecdsa::SigningKey;
993 use p256::ecdsa::signature::Signer;
994 use rand::rngs::OsRng;
995
996 use crate::service::SigV4aGate;
997 use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
998
999 fn lower_hex(bytes: &[u8]) -> String {
1000 let mut s = String::with_capacity(bytes.len() * 2);
1001 for b in bytes {
1002 s.push_str(&format!("{b:02x}"));
1003 }
1004 s
1005 }
1006
1007 /// Build a `Request<()>` with the given method, path, and headers.
1008 fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
1009 let mut b = Request::builder().method(method).uri(path);
1010 for (k, v) in headers {
1011 b = b.header(*k, *v);
1012 }
1013 b.body(()).expect("request builder")
1014 }
1015
1016 /// Build the SigV4a Authorization header for the given access-key,
1017 /// signed-headers list, and signature (lowercase hex DER).
1018 fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
1019 format!(
1020 "AWS4-ECDSA-P256-SHA256 \
1021 Credential={access_key}/20260513/s3/aws4_request, \
1022 SignedHeaders={}, \
1023 Signature={sig_hex}",
1024 signed_headers.join(";")
1025 )
1026 }
1027
1028 /// Build a fully-signed SigV4a `Request<()>` ready for the gate to
1029 /// verify. Returns the request and the verifying key it should be
1030 /// loaded against.
1031 fn make_signed_request(
1032 access_key: &str,
1033 method: Method,
1034 path: &str,
1035 region_set: &str,
1036 ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
1037 let signing = SigningKey::random(&mut OsRng);
1038 let verifying = p256::ecdsa::VerifyingKey::from(&signing);
1039 let signed_headers_list = [
1040 "host",
1041 "x-amz-content-sha256",
1042 "x-amz-date",
1043 REGION_SET_HEADER,
1044 ];
1045 // Build the request first WITHOUT the Authorization header so we
1046 // can compute canonical bytes and sign them; then re-build the
1047 // request with the Authorization header attached.
1048 let pre = Request::builder()
1049 .method(method.clone())
1050 .uri(path)
1051 .header("host", "s3.example.com")
1052 .header(
1053 "x-amz-content-sha256",
1054 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1055 )
1056 .header("x-amz-date", "20260513T120000Z")
1057 .header(REGION_SET_HEADER, region_set)
1058 .body(())
1059 .expect("pre-request");
1060 let signed_headers: Vec<String> = signed_headers_list
1061 .iter()
1062 .map(|s| (*s).to_string())
1063 .collect();
1064 let canonical =
1065 build_canonical_request_bytes(&pre, &signed_headers).expect("test fixture canonical");
1066 // v0.8.12 #126 (MED-A): sign the AWS-spec string-to-sign so
1067 // the routing-layer SigV4a fixture matches the new
1068 // `verify_request` body (which hashes the canonical request
1069 // and signs the algo / date / scope / hash concatenation).
1070 let canonical_hash = {
1071 use sha2::{Digest, Sha256};
1072 let mut h = Sha256::new();
1073 h.update(&canonical);
1074 let out = h.finalize();
1075 let mut s = String::with_capacity(out.len() * 2);
1076 for b in out {
1077 use std::fmt::Write as _;
1078 let _ = write!(s, "{b:02x}");
1079 }
1080 s
1081 };
1082 let sts = format!(
1083 "AWS4-ECDSA-P256-SHA256\n20260513T120000Z\n20260513/s3/aws4_request\n{canonical_hash}"
1084 );
1085 let sig: p256::ecdsa::Signature = signing.sign(sts.as_bytes());
1086 let sig_hex = lower_hex(sig.to_der().as_bytes());
1087 let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
1088
1089 // Rebuild with the Authorization header — every other header
1090 // value is identical so the canonical bytes the gate computes
1091 // match what we signed.
1092 let r = Request::builder()
1093 .method(method)
1094 .uri(path)
1095 .header("host", "s3.example.com")
1096 .header(
1097 "x-amz-content-sha256",
1098 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1099 )
1100 .header("x-amz-date", "20260513T120000Z")
1101 .header(REGION_SET_HEADER, region_set)
1102 .header("authorization", auth)
1103 .body(())
1104 .expect("signed request");
1105 (r, verifying)
1106 }
1107
1108 fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
1109 let mut m = HashMap::new();
1110 m.insert(access_key.to_string(), vk);
1111 let store = Arc::new(SigV4aCredentialStore::from_map(m));
1112 Arc::new(SigV4aGate::new(store))
1113 }
1114
1115 /// Drain a `s3s::Body` into bytes for body-content assertions.
1116 async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
1117 resp.into_body()
1118 .collect()
1119 .await
1120 .expect("body collect")
1121 .to_bytes()
1122 .to_vec()
1123 }
1124
1125 /// v0.8.4 #76: pinned `now` matching the `x-amz-date: 20260513T120000Z`
1126 /// the test fixtures stamp. Without this the freshness check would
1127 /// reject every gate test (the timestamp would be days/weeks old by
1128 /// the time CI runs). Production callers use `try_sigv4a_verify`
1129 /// (which calls `Utc::now()`).
1130 fn fixture_now() -> chrono::DateTime<chrono::Utc> {
1131 chrono::DateTime::parse_from_rfc3339("2026-05-13T12:00:00Z")
1132 .unwrap()
1133 .with_timezone(&chrono::Utc)
1134 }
1135
1136 #[test]
1137 fn no_sigv4a_prefix_returns_none() {
1138 // Plain SigV4 (HMAC-SHA256) request — gate must defer to s3s.
1139 let (_, vk) = (
1140 (),
1141 p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
1142 );
1143 let gate = make_gate_with("AKIAOK", vk);
1144 let r = req(
1145 Method::GET,
1146 "/bucket/key",
1147 &[(
1148 "authorization",
1149 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1150 SignedHeaders=host, Signature=deadbeef",
1151 )],
1152 );
1153 assert!(
1154 try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now()).is_none(),
1155 "plain SigV4 request must fall through to the inner service"
1156 );
1157 }
1158
1159 #[test]
1160 fn sigv4a_valid_signature_returns_ok() {
1161 let (r, vk) =
1162 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
1163 let gate = make_gate_with("AKIAOK", vk);
1164 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1165 .expect("must intercept SigV4a request");
1166 assert!(
1167 result.is_ok(),
1168 "valid SigV4a signature must verify: {result:?}"
1169 );
1170 }
1171
1172 #[tokio::test]
1173 async fn sigv4a_tampered_signature_returns_403() {
1174 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1175 let gate = make_gate_with("AKIAOK", vk);
1176
1177 // Tamper one byte of the signature hex inside the Authorization
1178 // header — the DER decode may still succeed, but ECDSA verify
1179 // will fail (or the DER decode itself will fail; both surface
1180 // as `SignatureDoesNotMatch`).
1181 let auth = r
1182 .headers()
1183 .get("authorization")
1184 .and_then(|v| v.to_str().ok())
1185 .expect("auth header")
1186 .to_string();
1187 // Flip the last hex char to corrupt the signature.
1188 let mut chars: Vec<char> = auth.chars().collect();
1189 let last = chars.len() - 1;
1190 chars[last] = if chars[last] == '0' { '1' } else { '0' };
1191 let tampered_auth: String = chars.into_iter().collect();
1192 let tampered = req(
1193 Method::GET,
1194 "/bucket/key",
1195 &[
1196 ("host", "s3.example.com"),
1197 (
1198 "x-amz-content-sha256",
1199 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1200 ),
1201 ("x-amz-date", "20260513T120000Z"),
1202 (REGION_SET_HEADER, "us-east-1"),
1203 ("authorization", &tampered_auth),
1204 ],
1205 );
1206 let result = try_sigv4a_verify_at(&tampered, Some(&gate), "us-east-1", fixture_now())
1207 .expect("must intercept SigV4a request");
1208 let resp = result.expect_err("tampered signature must surface a 403 response");
1209 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1210 let body = body_to_bytes(resp).await;
1211 let body_str = String::from_utf8(body).expect("xml utf-8");
1212 assert!(
1213 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1214 "403 body must surface SignatureDoesNotMatch: {body_str}"
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn sigv4a_region_set_mismatch_returns_403() {
1220 // Sign for `us-east-1` only, then verify with the listener
1221 // region claiming `eu-west-1` — must fail with
1222 // SignatureDoesNotMatch (the region-set check sits inside the
1223 // gate's verify path, and any failure there folds to
1224 // SignatureDoesNotMatch).
1225 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1226 let gate = make_gate_with("AKIAOK", vk);
1227 let result = try_sigv4a_verify_at(&r, Some(&gate), "eu-west-1", fixture_now())
1228 .expect("must intercept SigV4a request");
1229 let resp = result.expect_err("region mismatch must produce 403");
1230 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1231 let body = body_to_bytes(resp).await;
1232 let body_str = String::from_utf8(body).expect("xml utf-8");
1233 assert!(
1234 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1235 "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1236 );
1237 }
1238
1239 #[test]
1240 fn no_gate_attached_returns_none() {
1241 // Even a SigV4a-shaped request returns None when no gate is
1242 // installed — the listener will hand it to s3s, which surfaces
1243 // its own "unknown algorithm" error so the misconfiguration is
1244 // visible to the operator.
1245 let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1246 assert!(
1247 try_sigv4a_verify_at(&r, None, "us-east-1", fixture_now()).is_none(),
1248 "missing gate must defer to inner service"
1249 );
1250 }
1251
1252 #[tokio::test]
1253 async fn unknown_access_key_returns_403_invalid_access_key_id() {
1254 // Sign with one key but load the credential store with a
1255 // different access-key-id → InvalidAccessKeyId.
1256 let (r, _vk_unused) =
1257 make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1258 let other_signing = SigningKey::random(&mut OsRng);
1259 let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1260 let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1261 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1262 .expect("must intercept SigV4a request");
1263 let resp = result.expect_err("unknown key must produce 403");
1264 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1265 let body = body_to_bytes(resp).await;
1266 let body_str = String::from_utf8(body).expect("xml utf-8");
1267 assert!(
1268 body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1269 "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1270 );
1271 }
1272
1273 #[tokio::test]
1274 async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1275 // Some legacy clients stamp the `X-Amz-Region-Set` header
1276 // before swapping the algorithm string. `detect` flags this as
1277 // SigV4a-shaped but we cannot verify without a parseable
1278 // Authorization → fail closed (SignatureDoesNotMatch).
1279 let signing = SigningKey::random(&mut OsRng);
1280 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1281 let gate = make_gate_with("AKIAOK", vk);
1282 let r = req(
1283 Method::GET,
1284 "/bucket/key",
1285 &[
1286 // SigV4 algorithm + region-set header → detected, but
1287 // the Authorization is plain SigV4 so `parse_authorization_header`
1288 // returns None.
1289 (
1290 "authorization",
1291 "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1292 SignedHeaders=host, Signature=deadbeef",
1293 ),
1294 (REGION_SET_HEADER, "us-east-1"),
1295 ],
1296 );
1297 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1298 .expect("must intercept SigV4a-shaped request");
1299 let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1300 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1301 let body = body_to_bytes(resp).await;
1302 let body_str = String::from_utf8(body).expect("xml utf-8");
1303 assert!(
1304 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1305 "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1306 );
1307 }
1308
1309 /// v0.8.4 #76 (audit H-6): captured-request replay outside the
1310 /// 15-min window → 403 RequestTimeTooSkewed (not
1311 /// SignatureDoesNotMatch). This is the headline gate-level
1312 /// behaviour change; pre-#76 the same captured request would have
1313 /// reached the inner service, allowing destructive replay (DELETE
1314 /// included).
1315 #[tokio::test]
1316 async fn sigv4a_replay_outside_window_returns_403_request_time_too_skewed() {
1317 let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1318 let gate = make_gate_with("AKIAOK", vk);
1319 // Request stamped 20260513T120000Z; "now" is 30 min later → drift
1320 // 1800s, beyond the 900s default tolerance.
1321 let now = chrono::DateTime::parse_from_rfc3339("2026-05-13T12:30:00Z")
1322 .unwrap()
1323 .with_timezone(&chrono::Utc);
1324 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", now)
1325 .expect("must intercept SigV4a request");
1326 let resp = result.expect_err("replay outside window must reject");
1327 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1328 let body = body_to_bytes(resp).await;
1329 let body_str = String::from_utf8(body).expect("xml utf-8");
1330 assert!(
1331 body_str.contains("<Code>RequestTimeTooSkewed</Code>"),
1332 "replay outside window must surface RequestTimeTooSkewed: {body_str}"
1333 );
1334 }
1335
1336 /// Cover the canonical-request builder directly: empty query
1337 /// string, sorted multi-pair query, and header value collapsed
1338 /// whitespace all hit the right code paths.
1339 #[test]
1340 fn canonical_request_bytes_format() {
1341 let r = req(
1342 Method::PUT,
1343 "/bucket/key?z=1&a=2",
1344 &[
1345 ("host", "s3.example.com"),
1346 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1347 ("x-amz-date", " 20260513T120000Z "),
1348 ],
1349 );
1350 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1351 .iter()
1352 .map(|s| (*s).into())
1353 .collect();
1354 let bytes =
1355 build_canonical_request_bytes(&r, &signed).expect("canonical request bytes must build");
1356 let s = std::str::from_utf8(&bytes).expect("utf-8");
1357 let expected = "PUT\n\
1358 /bucket/key\n\
1359 a=2&z=1\n\
1360 host:s3.example.com\n\
1361 x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1362 x-amz-date:20260513T120000Z\n\
1363 \n\
1364 host;x-amz-content-sha256;x-amz-date\n\
1365 UNSIGNED-PAYLOAD";
1366 assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1367 }
1368
1369 /// v0.8.5 #84 H-4: duplicate `x-amz-date` headers must be rejected
1370 /// at canonical-request build time (not silently coalesced to the
1371 /// first value). HTTP/1.1 spec already forbids duplicates of
1372 /// `host` / `x-amz-date`; AWS SDKs never emit them; so any
1373 /// duplicate must be malicious or broken — single-value reject is
1374 /// the safe choice (see [`build_canonical_request_bytes`] doc).
1375 #[test]
1376 fn sigv4a_duplicate_x_amz_date_rejected() {
1377 // Two x-amz-date headers — first one matches the signature the
1378 // gate expects, second one is what a downstream parser might
1379 // pick up. This is the textbook auth-confusion vector.
1380 let r = Request::builder()
1381 .method(Method::GET)
1382 .uri("/b/k")
1383 .header("host", "s3.example.com")
1384 .header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
1385 .header("x-amz-date", "20260513T120000Z")
1386 .header("x-amz-date", "20260513T130000Z")
1387 .body(())
1388 .expect("dup-header request");
1389 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1390 .iter()
1391 .map(|s| (*s).into())
1392 .collect();
1393 let err = build_canonical_request_bytes(&r, &signed)
1394 .expect_err("duplicate x-amz-date must reject");
1395 match err {
1396 crate::sigv4a::SigV4aError::DuplicateSignedHeader { header } => {
1397 assert_eq!(header, "x-amz-date");
1398 }
1399 other => panic!("expected DuplicateSignedHeader, got {other:?}"),
1400 }
1401 }
1402
1403 /// v0.8.5 #84 H-4: counterpart to the duplicate-reject test —
1404 /// single-occurrence headers on the same path stay accepted.
1405 /// Guards against a regression where the duplicate-detect logic
1406 /// is over-eager and trips on a normally-formed request.
1407 #[test]
1408 fn sigv4a_canonicalization_single_header_passes() {
1409 let r = req(
1410 Method::GET,
1411 "/b/k",
1412 &[
1413 ("host", "s3.example.com"),
1414 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1415 ("x-amz-date", "20260513T120000Z"),
1416 ],
1417 );
1418 let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1419 .iter()
1420 .map(|s| (*s).into())
1421 .collect();
1422 let bytes =
1423 build_canonical_request_bytes(&r, &signed).expect("single-occurrence must accept");
1424 // Body content not asserted in detail (covered by
1425 // canonical_request_bytes_format); just confirm the bytes
1426 // parse as utf-8 and contain the date verbatim.
1427 let s = std::str::from_utf8(&bytes).expect("utf-8");
1428 assert!(
1429 s.contains("x-amz-date:20260513T120000Z"),
1430 "canonical bytes must echo the single x-amz-date verbatim:\n{s}"
1431 );
1432 }
1433
1434 /// v0.8.5 #84 H-4: end-to-end through the
1435 /// [`try_sigv4a_verify_at`] gate — duplicate `x-amz-date` on a
1436 /// SigV4a-shaped request must surface 403 SignatureDoesNotMatch
1437 /// (not silently authenticate against the first value).
1438 #[tokio::test]
1439 async fn sigv4a_pre_route_rejects_duplicate_signed_header() {
1440 let signing = SigningKey::random(&mut OsRng);
1441 let vk = p256::ecdsa::VerifyingKey::from(&signing);
1442 let gate = make_gate_with("AKIAOK", vk);
1443 // Authorization header lists x-amz-date in SignedHeaders —
1444 // signature value itself can be garbage; the duplicate-detect
1445 // path runs strictly before any ECDSA math.
1446 let auth = build_auth_header(
1447 "AKIAOK",
1448 &[
1449 "host",
1450 "x-amz-content-sha256",
1451 "x-amz-date",
1452 REGION_SET_HEADER,
1453 ],
1454 "deadbeef",
1455 );
1456 let r = Request::builder()
1457 .method(Method::GET)
1458 .uri("/bucket/key")
1459 .header("host", "s3.example.com")
1460 .header(
1461 "x-amz-content-sha256",
1462 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1463 )
1464 .header("x-amz-date", "20260513T120000Z")
1465 .header("x-amz-date", "20260513T130000Z")
1466 .header(REGION_SET_HEADER, "us-east-1")
1467 .header("authorization", auth)
1468 .body(())
1469 .expect("dup-header sigv4a request");
1470 let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1471 .expect("must intercept SigV4a request");
1472 let resp = result.expect_err("duplicate signed header must reject at the gate");
1473 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1474 let body = body_to_bytes(resp).await;
1475 let body_str = String::from_utf8(body).expect("xml utf-8");
1476 assert!(
1477 body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1478 "duplicate signed header must surface SignatureDoesNotMatch: {body_str}"
1479 );
1480 assert!(
1481 body_str.contains("duplicate signed header"),
1482 "diagnostic must mention duplicate header: {body_str}"
1483 );
1484 }
1485}