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