From 57fc885e43c0f9d9809e4e973b2ab1f3014abb98 Mon Sep 17 00:00:00 2001 From: Nugine Date: Mon, 15 Jun 2026 10:50:21 +0800 Subject: [PATCH 1/8] release v0.14.0-alpha.1 (#614) --- Cargo.lock | 16 ++++++++-------- codegen/Cargo.toml | 2 +- crates/s3s-aws/Cargo.toml | 4 ++-- crates/s3s-e2e/Cargo.toml | 6 +++--- crates/s3s-fs/Cargo.toml | 6 +++--- crates/s3s-model/Cargo.toml | 2 +- crates/s3s-policy/Cargo.toml | 2 +- crates/s3s-proxy/Cargo.toml | 6 +++--- crates/s3s-test/Cargo.toml | 2 +- crates/s3s-wasm/Cargo.toml | 2 +- crates/s3s/Cargo.toml | 2 +- justfile | 16 ++++++++-------- 12 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac9ccbf6..61383c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2932,7 +2932,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "s3s" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "arc-swap", "arrayvec", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "s3s-aws" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "async-trait", "aws-sdk-s3", @@ -3022,7 +3022,7 @@ dependencies = [ [[package]] name = "s3s-e2e" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "s3s-fs" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3081,7 +3081,7 @@ dependencies = [ [[package]] name = "s3s-model" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "anyhow", "numeric_cast", @@ -3091,7 +3091,7 @@ dependencies = [ [[package]] name = "s3s-policy" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "indexmap", "serde", @@ -3101,7 +3101,7 @@ dependencies = [ [[package]] name = "s3s-proxy" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -3117,7 +3117,7 @@ dependencies = [ [[package]] name = "s3s-test" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" dependencies = [ "backtrace", "clap", diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index f5f7c02a..0895b52c 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -19,5 +19,5 @@ regex = "1.12.3" serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } serde_urlencoded = "0.7.1" -s3s-model = { version = "0.14.0-dev", path = "../crates/s3s-model" } +s3s-model = { version = "0.14.0-alpha.1", path = "../crates/s3s-model" } http.workspace = true diff --git a/crates/s3s-aws/Cargo.toml b/crates/s3s-aws/Cargo.toml index 98c38537..f4c8b2f0 100644 --- a/crates/s3s-aws/Cargo.toml +++ b/crates/s3s-aws/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-aws" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "S3 service adapter integrated with aws-sdk-s3" readme = "../../README.md" keywords = ["s3"] @@ -23,7 +23,7 @@ aws-smithy-runtime-api = { workspace = true, features = ["client", "http-1x"] } aws-smithy-types = { workspace = true, features = ["http-body-1-x"] } aws-smithy-types-convert = { workspace = true, features = ["convert-time"] } hyper.workspace = true -s3s = { version = "0.14.0-dev", path = "../s3s", default-features = false } +s3s = { version = "0.14.0-alpha.1", path = "../s3s", default-features = false } std-next.workspace = true sync_wrapper = "1.0.2" tracing.workspace = true diff --git a/crates/s3s-e2e/Cargo.toml b/crates/s3s-e2e/Cargo.toml index 71c21c7a..38b33434 100644 --- a/crates/s3s-e2e/Cargo.toml +++ b/crates/s3s-e2e/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-e2e" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "s3s test suite" readme = "../../README.md" keywords = ["s3"] @@ -14,7 +14,7 @@ rust-version.workspace = true workspace = true [dependencies] -s3s-test = { version = "0.14.0-dev", path = "../s3s-test" } +s3s-test = { version = "0.14.0-alpha.1", path = "../s3s-test" } tracing.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } aws-credential-types.workspace = true @@ -29,4 +29,4 @@ base64-simd.workspace = true reqwest.workspace = true [build-dependencies] -s3s-test = { version = "0.14.0-dev", path = "../s3s-test" } +s3s-test = { version = "0.14.0-alpha.1", path = "../s3s-test" } diff --git a/crates/s3s-fs/Cargo.toml b/crates/s3s-fs/Cargo.toml index a8ba8640..4ba745fd 100644 --- a/crates/s3s-fs/Cargo.toml +++ b/crates/s3s-fs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-fs" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "An experimental S3 server based on file system" readme = "../../README.md" keywords = ["s3"] @@ -39,7 +39,7 @@ hyper-util = { workspace = true, optional = true, features = [ mime.workspace = true numeric_cast.workspace = true path-absolutize.workspace = true -s3s = { version = "0.14.0-dev", path = "../s3s" } +s3s = { version = "0.14.0-alpha.1", path = "../s3s" } serde.workspace = true serde_json.workspace = true std-next.workspace = true @@ -63,6 +63,6 @@ futures-util.workspace = true hyper = { workspace = true, features = ["http1", "http2"] } hyper-util = { workspace = true, features = ["server-auto", "server-graceful", "http1", "http2", "tokio"] } opendal = { workspace = true, default-features = false, features = ["services-s3", "executors-tokio", "reqwest-rustls-tls"] } -s3s-aws = { version = "0.14.0-dev", path = "../s3s-aws" } +s3s-aws = { version = "0.14.0-alpha.1", path = "../s3s-aws" } tokio = { workspace = true, features = ["full"] } tracing-subscriber.workspace = true diff --git a/crates/s3s-model/Cargo.toml b/crates/s3s-model/Cargo.toml index 5d513baa..1df56ebf 100644 --- a/crates/s3s-model/Cargo.toml +++ b/crates/s3s-model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-model" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "S3 Protocol Model" readme = "../../README.md" keywords = ["s3"] diff --git a/crates/s3s-policy/Cargo.toml b/crates/s3s-policy/Cargo.toml index 03eeb93b..bfb0cc2d 100644 --- a/crates/s3s-policy/Cargo.toml +++ b/crates/s3s-policy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-policy" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "S3 Policy Language" readme = "../../README.md" keywords = ["s3"] diff --git a/crates/s3s-proxy/Cargo.toml b/crates/s3s-proxy/Cargo.toml index f9ba2e91..3f4d537d 100644 --- a/crates/s3s-proxy/Cargo.toml +++ b/crates/s3s-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-proxy" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "S3 Proxy" readme = "../../README.md" keywords = ["s3"] @@ -25,8 +25,8 @@ hyper-util = { workspace = true, features = [ "http2", "tokio", ] } -s3s = { version = "0.14.0-dev", path = "../s3s" } -s3s-aws = { version = "0.14.0-dev", path = "../s3s-aws" } +s3s = { version = "0.14.0-alpha.1", path = "../s3s" } +s3s-aws = { version = "0.14.0-alpha.1", path = "../s3s-aws" } tokio = { workspace = true, features = ["full"] } tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/s3s-test/Cargo.toml b/crates/s3s-test/Cargo.toml index f4d86f88..b6a290c3 100644 --- a/crates/s3s-test/Cargo.toml +++ b/crates/s3s-test/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s-test" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "s3s test suite" readme = "../../README.md" keywords = ["s3"] diff --git a/crates/s3s-wasm/Cargo.toml b/crates/s3s-wasm/Cargo.toml index cc047087..32294991 100644 --- a/crates/s3s-wasm/Cargo.toml +++ b/crates/s3s-wasm/Cargo.toml @@ -11,7 +11,7 @@ publish = false futures = { workspace = true, features = ["executor"] } getrandom = { version = "0.4.2", features = ["wasm_js"] } http.workspace = true -s3s = { version = "0.14.0-dev", path = "../s3s", default-features = false } +s3s = { version = "0.14.0-alpha.1", path = "../s3s", default-features = false } [lints] workspace = true diff --git a/crates/s3s/Cargo.toml b/crates/s3s/Cargo.toml index 546e9f61..95329cde 100644 --- a/crates/s3s/Cargo.toml +++ b/crates/s3s/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "s3s" -version = "0.14.0-dev" +version = "0.14.0-alpha.1" description = "S3 Service Adapter" readme = "../../README.md" keywords = ["s3"] diff --git a/justfile b/justfile index de3ff2b9..9abedb78 100644 --- a/justfile +++ b/justfile @@ -43,14 +43,14 @@ coverage *ARGS: # ------------------------------------------------ sync-version: - cargo set-version -p s3s 0.14.0-dev - cargo set-version -p s3s-aws 0.14.0-dev - cargo set-version -p s3s-model 0.14.0-dev - cargo set-version -p s3s-policy 0.14.0-dev - cargo set-version -p s3s-test 0.14.0-dev - cargo set-version -p s3s-proxy 0.14.0-dev - cargo set-version -p s3s-fs 0.14.0-dev - cargo set-version -p s3s-e2e 0.14.0-dev + cargo set-version -p s3s 0.14.0-alpha.1 + cargo set-version -p s3s-aws 0.14.0-alpha.1 + cargo set-version -p s3s-model 0.14.0-alpha.1 + cargo set-version -p s3s-policy 0.14.0-alpha.1 + cargo set-version -p s3s-test 0.14.0-alpha.1 + cargo set-version -p s3s-proxy 0.14.0-alpha.1 + cargo set-version -p s3s-fs 0.14.0-alpha.1 + cargo set-version -p s3s-e2e 0.14.0-alpha.1 # ------------------------------------------------ From 086fcb29a8618651ef4a6ff941349d12d1377ff5 Mon Sep 17 00:00:00 2001 From: Nugine Date: Mon, 15 Jun 2026 11:14:08 +0800 Subject: [PATCH 2/8] ci: switch to trusted publishing via OIDC (#615) --- .github/workflows/publish.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fe58d017..7321efb6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,13 +8,17 @@ on: jobs: publish: runs-on: ubuntu-latest + environment: crates.io + permissions: + id-token: write steps: - uses: actions/checkout@v6 - - uses: taiki-e/install-action@just - uses: dtolnay/rust-toolchain@nightly + - uses: rust-lang/crates-io-auth-action@v1 + id: auth - name: Publish all crates env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} run: | cargo publish -p s3s --dry-run cargo publish --workspace From c5018e09434aecaf656338fcd4eabd4a3cde3351 Mon Sep 17 00:00:00 2001 From: houseme Date: Fri, 19 Jun 2026 18:11:01 +0800 Subject: [PATCH 3/8] fix(sigv4): harden replay window and compares --- crates/s3s/src/ops/signature.rs | 229 +++++++++++++++++++++++++++++--- crates/s3s/src/ops/tests.rs | 36 +++-- 2 files changed, 239 insertions(+), 26 deletions(-) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index bf9105e6..c333c411 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -24,6 +24,7 @@ use std::sync::Arc; use hyper::Method; use hyper::Uri; use mime::Mime; +use subtle::ConstantTimeEq; use tracing::debug; /// Maximum allowed size for STS request body (8KB should be enough for operations like `AssumeRole`) @@ -116,6 +117,23 @@ struct SignatureVerificationContext<'a> { service: &'a str, } +fn sig_v4_signatures_match(actual_signature: &str, expected_signature: &str) -> bool { + actual_signature.as_bytes().ct_eq(expected_signature.as_bytes()).into() +} + +fn validate_sig_v4_clock_skew(amz_date: &AmzDate, config: &Arc) -> S3Result<()> { + let request_time = amz_date.to_time().ok_or_else(|| invalid_request!("invalid amz date"))?; + let now = time::OffsetDateTime::now_utc(); + let duration = now - request_time; + let max_skew_time = time::Duration::seconds(i64::from(config.snapshot().presigned_url_max_skew_time_secs)); + + if duration.abs() > max_skew_time { + return Err(s3_error!(RequestTimeTooSkewed, "request time is too far from server time")); + } + + Ok(()) +} + impl SignatureVerificationContext<'_> { fn verify_with_raw_path_fallback( &self, @@ -125,7 +143,7 @@ impl SignatureVerificationContext<'_> { let string_to_sign = sig_v4::create_string_to_sign(canonical_request, self.amz_date, self.region, self.service); let signature = sig_v4::calculate_signature(&string_to_sign, self.secret_key, self.amz_date, self.region, self.service); - if signature == self.expected_signature { + if sig_v4_signatures_match(&signature, self.expected_signature) { return Ok(signature); } @@ -139,7 +157,7 @@ impl SignatureVerificationContext<'_> { let raw_signature = sig_v4::calculate_signature(&string_to_sign, self.secret_key, self.amz_date, self.region, self.service); - if raw_signature != self.expected_signature { + if !sig_v4_signatures_match(&raw_signature, self.expected_signature) { debug!(?signature, ?raw_signature, expected=?self.expected_signature, "signature mismatch"); return Err(s3_error!(SignatureDoesNotMatch)); } @@ -249,6 +267,7 @@ impl SignatureContext<'_> { CredentialV4::parse(info.x_amz_credential).map_err(|_| invalid_request!("invalid field: x-amz-credential"))?; let amz_date = AmzDate::parse(info.x_amz_date).map_err(|_| invalid_request!("invalid field: x-amz-date"))?; + validate_sig_v4_clock_skew(&amz_date, self.config)?; let access_key = credential.access_key_id.to_owned(); let secret_key = auth.get_secret_key(&access_key).await?; @@ -268,7 +287,7 @@ impl SignatureContext<'_> { let signature = sig_v4::calculate_signature(string_to_sign, &secret_key, &amz_date, region, service); let expected_signature = info.x_amz_signature; - if signature != expected_signature { + if !sig_v4_signatures_match(&signature, expected_signature) { debug!(?signature, expected=?expected_signature, "signature mismatch"); return Err(s3_error!(SignatureDoesNotMatch)); } @@ -410,6 +429,7 @@ impl SignatureContext<'_> { let secret_key = auth.get_secret_key(access_key).await?; let amz_date = extract_amz_date(&self.hs)?.ok_or_else(|| invalid_request!("missing header: x-amz-date"))?; + validate_sig_v4_clock_skew(&amz_date, self.config)?; let is_stream = amz_content_sha256.is_some_and(|v| v.is_streaming()); @@ -673,6 +693,18 @@ impl SignatureContext<'_> { mod tests { use super::*; + fn fmt_current_amz_date(dt: time::OffsetDateTime) -> String { + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + dt.year(), + u8::from(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second() + ) + } + #[test] fn test_extract_amz_content_sha256_missing() { // Test that extract_amz_content_sha256 returns None when header is missing @@ -1117,11 +1149,12 @@ file content\r\n\ let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = AmzDate::parse(&amz_date_str).unwrap(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ]); let canonical_requests = [ @@ -1145,13 +1178,14 @@ file content\r\n\ let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" + "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", + amz_date.fmt_date() ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ]); let mut body = Body::empty(); @@ -1199,11 +1233,12 @@ file content\r\n\ let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = AmzDate::parse(&amz_date_str).unwrap(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ]); let canonical_request = sig_v4::create_canonical_request( &method, @@ -1215,12 +1250,13 @@ file content\r\n\ let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" + "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", + amz_date.fmt_date() ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ]); let mut body = Body::empty(); @@ -1268,13 +1304,14 @@ file content\r\n\ let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = AmzDate::parse(&amz_date_str).unwrap(); let chunk_data = Bytes::from_static(b"hello"); let decoded_content_length = chunk_data.len(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ("x-amz-decoded-content-length", "5"), ]); @@ -1311,13 +1348,14 @@ file content\r\n\ let content_length = u64::try_from(streaming_body.len()).unwrap(); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length, Signature={seed_signature}" + "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length, Signature={seed_signature}", + amz_date.fmt_date() ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ("x-amz-date", "20130524T000000Z"), + ("x-amz-date", amz_date_str.as_str()), ("x-amz-decoded-content-length", "5"), ]); @@ -1421,4 +1459,165 @@ file content\r\n\ assert_eq!(cred.region, None, "SigV2 carries no region"); assert_eq!(cred.service.as_deref(), Some("s3"), "SigV2 service is always 's3'"); } + + #[test] + fn sig_v4_signatures_match_reports_match_and_mismatch() { + assert!(sig_v4_signatures_match("abcd", "abcd")); + assert!(!sig_v4_signatures_match("abcd", "abce")); + assert!(!sig_v4_signatures_match("abcd", "abc")); + } + + #[tokio::test] + async fn v4_header_auth_rejects_stale_request_time() { + use crate::S3ErrorCode; + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let request_time = time::OffsetDateTime::now_utc() - time::Duration::minutes(16); + let amz_date_str = fmt_current_amz_date(request_time); + let amz_date = AmzDate::parse(&amz_date_str).unwrap(); + + let method = Method::GET; + let uri = Uri::from_static("https://s3.amazonaws.com/test.txt"); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", amz_date_str.as_str()), + ]); + let canonical_request = sig_v4::create_canonical_request( + &method, + "/test.txt", + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::Unsigned, + ); + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); + let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", + amz_date.fmt_date() + ); + + let headers = OrderedHeaders::from_slice_unchecked(&[ + ("authorization", authorization.as_str()), + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", amz_date_str.as_str()), + ]); + + let mut body = Body::empty(); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_11, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: None, + hs: headers, + decoded_uri_path: "/test.txt", + raw_uri_path: "/test.txt", + vh_bucket: None, + content_length: Some(0), + mime: None, + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let err = cx + .v4_check_header_auth() + .await + .expect_err("stale signed header request should be rejected"); + assert_eq!(err.code(), &S3ErrorCode::RequestTimeTooSkewed); + } + + #[tokio::test] + async fn v4_post_signature_rejects_stale_request_time() { + use crate::S3ErrorCode; + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let request_time = time::OffsetDateTime::now_utc() - time::Duration::minutes(16); + let amz_date_str = fmt_current_amz_date(request_time); + let amz_date = AmzDate::parse(&amz_date_str).unwrap(); + let policy_b64 = base64_simd::STANDARD.encode_to_string("{}"); + let signature = sig_v4::calculate_signature(&policy_b64, &secret_key, &amz_date, "us-east-1", "s3"); + let boundary = "boundary123"; + let body = format!( + concat!( + "\r\n--{boundary}\r\n", + "Content-Disposition: form-data; name=\"x-amz-signature\"\r\n\r\n", + "{signature}\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"policy\"\r\n\r\n", + "{policy_b64}\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"x-amz-algorithm\"\r\n\r\n", + "AWS4-HMAC-SHA256\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"x-amz-credential\"\r\n\r\n", + "{access_key}/{date}/us-east-1/s3/aws4_request\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"x-amz-date\"\r\n\r\n", + "{amz_date}\r\n", + "--{boundary}\r\n", + "Content-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n", + "Content-Type: text/plain\r\n\r\n", + "hello\r\n", + "--{boundary}--\r\n" + ), + access_key = access_key, + amz_date = amz_date_str, + boundary = boundary, + date = amz_date.fmt_date(), + policy_b64 = policy_b64, + signature = signature, + ); + + let mime: Mime = format!("multipart/form-data; boundary={boundary}").parse().unwrap(); + let method = Method::POST; + let uri = Uri::from_static("http://localhost/test-bucket"); + let mut body = Body::from(body); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_11, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: None, + hs: OrderedHeaders::from_slice_unchecked(&[]), + decoded_uri_path: "/test-bucket", + raw_uri_path: "/test-bucket", + vh_bucket: None, + content_length: None, + mime: Some(mime), + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let err = cx + .check_post_signature() + .await + .expect_err("stale signed POST policy should be rejected"); + assert_eq!(err.code(), &S3ErrorCode::RequestTimeTooSkewed); + } } diff --git a/crates/s3s/src/ops/tests.rs b/crates/s3s/src/ops/tests.rs index 7bf61ccb..bf46dfbb 100644 --- a/crates/s3s/src/ops/tests.rs +++ b/crates/s3s/src/ops/tests.rs @@ -1,5 +1,17 @@ use super::*; +fn fmt_current_amz_date(dt: time::OffsetDateTime) -> String { + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + dt.year(), + u8::from(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second() + ) +} + // use crate::service::S3Service; // use stdx::mem::output_size; @@ -476,8 +488,9 @@ async fn post_multipart_bucket_routes_to_post_object() { let key = "mc-test-object-7658"; let policy_b64 = "eyJleHBpcmF0aW9uIjoiMjAyMC0xMC0wM1QxMzoyNTo0Ny4yMThaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwibWMtdGVzdC1idWNrZXQtMzI1NjkiXSxbImVxIiwiJGtleSIsIm1jLXRlc3Qtb2JqZWN0LTc2NTgiXSxbImVxIiwiJHgtYW16LWRhdGUiLCIyMDIwMDkyNlQxMzI1NDdaIl0sWyJlcSIsIiR4LWFtei1hbGdvcml0aG0iLCJBV1M0LUhNQUMtU0hBMjU2Il0sWyJlcSIsIiR4LWFtei1jcmVkZW50aWFsIiwiQUtJQUlPU0ZPRE5ON0VYQU1QTEUvMjAyMDA5MjYvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJdXX0="; let algorithm = "AWS4-HMAC-SHA256"; - let credential = "AKIAIOSFODNN7EXAMPLE/20200926/us-east-1/s3/aws4_request"; - let amz_date = sig_v4::AmzDate::parse("20200926T132547Z").unwrap(); + let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); + let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); let region = "us-east-1"; let service = "s3"; let signature = sig_v4::calculate_signature(policy_b64, &secret_key, &amz_date, region, service); @@ -511,7 +524,7 @@ async fn post_multipart_bucket_routes_to_post_object() { "hello\r\n", "--{b}--\r\n" ), - amz_date = amz_date.fmt_iso8601(), + amz_date = amz_date_str, b = boundary, signature = signature, bucket = bucket, @@ -668,14 +681,14 @@ mod post_policy_test_helpers { let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; - let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); + let amz_date_str = super::fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; - let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; + let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); - let amz_date_str = amz_date.fmt_iso8601(); let fields = { let mut f = vec![ @@ -683,7 +696,7 @@ mod post_policy_test_helpers { ("bucket", bucket), ("policy", policy_b64.as_str()), ("x-amz-algorithm", algorithm), - ("x-amz-credential", credential), + ("x-amz-credential", credential.as_str()), ("x-amz-date", amz_date_str.as_str()), ("key", key), ]; @@ -726,12 +739,13 @@ mod post_policy_test_helpers { let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; - let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); + let amz_date_str = super::fmt_current_amz_date(time::OffsetDateTime::now_utc()); + let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; - let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; + let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); let body = build_multipart_fields( @@ -740,8 +754,8 @@ mod post_policy_test_helpers { ("bucket", bucket), ("policy", &policy_b64), ("x-amz-algorithm", algorithm), - ("x-amz-credential", credential), - ("x-amz-date", &amz_date.fmt_iso8601()), + ("x-amz-credential", credential.as_str()), + ("x-amz-date", amz_date_str.as_str()), ("key", key), ], boundary, From 03a710c121ef03c25394e5a6287aeb097ebd90f6 Mon Sep 17 00:00:00 2001 From: Nugine Date: Sun, 21 Jun 2026 17:34:29 +0800 Subject: [PATCH 4/8] fix(s3s/sigv4): restore fixed timestamps in tests, bypass clock skew via config Restore deterministic timestamps in existing SigV4 and POST policy tests, using presigned_url_max_skew_time_secs: u32::MAX to bypass clock-skew enforcement instead of switching to OffsetDateTime::now_utc(). - Derive stale-request offsets from config in new regression tests - Remove now-unused fmt_current_amz_date from tests.rs - Apply clippy field_reassign_with_default fix --- crates/s3s/src/ops/signature.rs | 60 +++++++++++++++++++-------------- crates/s3s/src/ops/tests.rs | 43 ++++++++++------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index c333c411..1145ca25 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -1137,24 +1137,27 @@ file content\r\n\ async fn v4_header_auth_accepts_standard_and_raw_uri_path_signatures() { use crate::auth::SecretKey; use crate::auth::SimpleAuth; - use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use crate::config::{S3Config, S3ConfigProvider, StaticConfigProvider}; use std::sync::Arc; let access_key = "AKIAIOSFODNN7EXAMPLE"; let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); let auth = SimpleAuth::from_single(access_key, secret_key.clone()); - let config: Arc = Arc::new(StaticConfigProvider::default()); + let s3_config = S3Config { + presigned_url_max_skew_time_secs: u32::MAX, + ..Default::default() + }; + let config: Arc = Arc::new(StaticConfigProvider::new(Arc::new(s3_config))); let method = Method::GET; let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = AmzDate::parse(&amz_date_str).unwrap(); + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ]); let canonical_requests = [ @@ -1178,14 +1181,13 @@ file content\r\n\ let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", - amz_date.fmt_date() + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ]); let mut body = Body::empty(); @@ -1221,24 +1223,27 @@ file content\r\n\ async fn v4_header_auth_uses_http2_authority_for_signed_host() { use crate::auth::SecretKey; use crate::auth::SimpleAuth; - use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use crate::config::{S3Config, S3ConfigProvider, StaticConfigProvider}; use std::sync::Arc; let access_key = "AKIAIOSFODNN7EXAMPLE"; let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); let auth = SimpleAuth::from_single(access_key, secret_key.clone()); - let config: Arc = Arc::new(StaticConfigProvider::default()); + let s3_config = S3Config { + presigned_url_max_skew_time_secs: u32::MAX, + ..Default::default() + }; + let config: Arc = Arc::new(StaticConfigProvider::new(Arc::new(s3_config))); let method = Method::GET; let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = AmzDate::parse(&amz_date_str).unwrap(); + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ]); let canonical_request = sig_v4::create_canonical_request( &method, @@ -1250,13 +1255,12 @@ file content\r\n\ let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}", - amz_date.fmt_date() + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ]); let mut body = Body::empty(); @@ -1291,27 +1295,30 @@ file content\r\n\ async fn v4_header_auth_raw_uri_path_signature_seeds_streaming_body() { use crate::auth::SecretKey; use crate::auth::SimpleAuth; - use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use crate::config::{S3Config, S3ConfigProvider, StaticConfigProvider}; use bytes::Bytes; use std::sync::Arc; let access_key = "AKIAIOSFODNN7EXAMPLE"; let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); let auth = SimpleAuth::from_single(access_key, secret_key.clone()); - let config: Arc = Arc::new(StaticConfigProvider::default()); + let s3_config = S3Config { + presigned_url_max_skew_time_secs: u32::MAX, + ..Default::default() + }; + let config: Arc = Arc::new(StaticConfigProvider::new(Arc::new(s3_config))); let method = Method::PUT; let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; - let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = AmzDate::parse(&amz_date_str).unwrap(); + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); let chunk_data = Bytes::from_static(b"hello"); let decoded_content_length = chunk_data.len(); let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ("x-amz-decoded-content-length", "5"), ]); @@ -1348,14 +1355,13 @@ file content\r\n\ let content_length = u64::try_from(streaming_body.len()).unwrap(); let authorization = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length, Signature={seed_signature}", - amz_date.fmt_date() + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length, Signature={seed_signature}" ); let headers = OrderedHeaders::from_slice_unchecked(&[ ("authorization", authorization.as_str()), ("host", "s3.amazonaws.com"), ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ("x-amz-date", amz_date_str.as_str()), + ("x-amz-date", "20130524T000000Z"), ("x-amz-decoded-content-length", "5"), ]); @@ -1480,7 +1486,8 @@ file content\r\n\ let auth = SimpleAuth::from_single(access_key, secret_key.clone()); let config: Arc = Arc::new(StaticConfigProvider::default()); - let request_time = time::OffsetDateTime::now_utc() - time::Duration::minutes(16); + let skew = time::Duration::seconds(i64::from(config.snapshot().presigned_url_max_skew_time_secs)); + let request_time = time::OffsetDateTime::now_utc() - skew - time::Duration::minutes(1); let amz_date_str = fmt_current_amz_date(request_time); let amz_date = AmzDate::parse(&amz_date_str).unwrap(); @@ -1553,7 +1560,8 @@ file content\r\n\ let auth = SimpleAuth::from_single(access_key, secret_key.clone()); let config: Arc = Arc::new(StaticConfigProvider::default()); - let request_time = time::OffsetDateTime::now_utc() - time::Duration::minutes(16); + let skew = time::Duration::seconds(i64::from(config.snapshot().presigned_url_max_skew_time_secs)); + let request_time = time::OffsetDateTime::now_utc() - skew - time::Duration::minutes(1); let amz_date_str = fmt_current_amz_date(request_time); let amz_date = AmzDate::parse(&amz_date_str).unwrap(); let policy_b64 = base64_simd::STANDARD.encode_to_string("{}"); diff --git a/crates/s3s/src/ops/tests.rs b/crates/s3s/src/ops/tests.rs index bf46dfbb..187de3d9 100644 --- a/crates/s3s/src/ops/tests.rs +++ b/crates/s3s/src/ops/tests.rs @@ -1,17 +1,5 @@ use super::*; -fn fmt_current_amz_date(dt: time::OffsetDateTime) -> String { - format!( - "{:04}{:02}{:02}T{:02}{:02}{:02}Z", - dt.year(), - u8::from(dt.month()), - dt.day(), - dt.hour(), - dt.minute(), - dt.second() - ) -} - // use crate::service::S3Service; // use stdx::mem::output_size; @@ -426,7 +414,7 @@ async fn presigned_url_expires_0_should_be_expired() { async fn post_multipart_bucket_routes_to_post_object() { use crate::S3Request; use crate::auth::{SecretKey, SimpleAuth}; - use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use crate::config::{S3Config, S3ConfigProvider, StaticConfigProvider}; use crate::http::{Body, Request}; use crate::ops::CallContext; use crate::sig_v4; @@ -465,7 +453,11 @@ async fn post_multipart_bucket_routes_to_post_object() { post_calls: AtomicUsize::new(0), }); let s3: Arc = test_s3.clone(); - let config: Arc = Arc::new(StaticConfigProvider::default()); + let s3_config = S3Config { + presigned_url_max_skew_time_secs: u32::MAX, + ..Default::default() + }; + let config: Arc = Arc::new(StaticConfigProvider::new(Arc::new(s3_config))); let access_key = "AKIAIOSFODNN7EXAMPLE"; let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); @@ -488,9 +480,9 @@ async fn post_multipart_bucket_routes_to_post_object() { let key = "mc-test-object-7658"; let policy_b64 = "eyJleHBpcmF0aW9uIjoiMjAyMC0xMC0wM1QxMzoyNTo0Ny4yMThaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwibWMtdGVzdC1idWNrZXQtMzI1NjkiXSxbImVxIiwiJGtleSIsIm1jLXRlc3Qtb2JqZWN0LTc2NTgiXSxbImVxIiwiJHgtYW16LWRhdGUiLCIyMDIwMDkyNlQxMzI1NDdaIl0sWyJlcSIsIiR4LWFtei1hbGdvcml0aG0iLCJBV1M0LUhNQUMtU0hBMjU2Il0sWyJlcSIsIiR4LWFtei1jcmVkZW50aWFsIiwiQUtJQUlPU0ZPRE5ON0VYQU1QTEUvMjAyMDA5MjYvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJdXX0="; let algorithm = "AWS4-HMAC-SHA256"; - let amz_date_str = fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); - let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); + let amz_date = sig_v4::AmzDate::parse("20200926T132547Z").unwrap(); + let amz_date_str = amz_date.fmt_iso8601(); + let credential = "AKIAIOSFODNN7EXAMPLE/20200926/us-east-1/s3/aws4_request"; let region = "us-east-1"; let service = "s3"; let signature = sig_v4::calculate_signature(policy_b64, &secret_key, &amz_date, region, service); @@ -597,6 +589,7 @@ mod post_policy_test_helpers { /// Create a test config with custom `post_object_max_file_size` pub fn create_test_config(post_object_max_file_size: u64) -> Arc { let config = S3Config { + presigned_url_max_skew_time_secs: u32::MAX, post_object_max_file_size, ..Default::default() }; @@ -681,14 +674,14 @@ mod post_policy_test_helpers { let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; - let amz_date_str = super::fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); + let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; - let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); + let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); + let amz_date_str = amz_date.fmt_iso8601(); let fields = { let mut f = vec![ @@ -696,7 +689,7 @@ mod post_policy_test_helpers { ("bucket", bucket), ("policy", policy_b64.as_str()), ("x-amz-algorithm", algorithm), - ("x-amz-credential", credential.as_str()), + ("x-amz-credential", credential), ("x-amz-date", amz_date_str.as_str()), ("key", key), ]; @@ -739,14 +732,14 @@ mod post_policy_test_helpers { let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; - let amz_date_str = super::fmt_current_amz_date(time::OffsetDateTime::now_utc()); - let amz_date = sig_v4::AmzDate::parse(&amz_date_str).unwrap(); + let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; - let credential = format!("AKIAIOSFODNN7EXAMPLE/{}/us-east-1/s3/aws4_request", amz_date.fmt_date()); + let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); + let amz_date_str = amz_date.fmt_iso8601(); let body = build_multipart_fields( &[ @@ -754,7 +747,7 @@ mod post_policy_test_helpers { ("bucket", bucket), ("policy", &policy_b64), ("x-amz-algorithm", algorithm), - ("x-amz-credential", credential.as_str()), + ("x-amz-credential", credential), ("x-amz-date", amz_date_str.as_str()), ("key", key), ], From 29d41b16c777a39bb916d68b73d30dd5c3fab89a Mon Sep 17 00:00:00 2001 From: Nugine Date: Sun, 21 Jun 2026 19:43:19 +0800 Subject: [PATCH 5/8] refactor(s3s/sigv4): pass now and &S3Config to validate_sig_v4_clock_skew Expose the `now` instant and `S3Config` snapshot as explicit parameters so callers control timing and the caller resolves the config snapshot. Removes the hidden `now_utc()` call and `.snapshot()` resolution inside the function. --- crates/s3s/src/ops/signature.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index 1145ca25..487d4598 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -1,6 +1,6 @@ use crate::auth::S3Auth; use crate::auth::SecretKey; -use crate::config::S3ConfigProvider; +use crate::config::{S3Config, S3ConfigProvider}; use crate::error::*; use crate::http; use crate::http::{AwsChunkedStream, Body, Multipart, MultipartLimits}; @@ -121,11 +121,10 @@ fn sig_v4_signatures_match(actual_signature: &str, expected_signature: &str) -> actual_signature.as_bytes().ct_eq(expected_signature.as_bytes()).into() } -fn validate_sig_v4_clock_skew(amz_date: &AmzDate, config: &Arc) -> S3Result<()> { +fn validate_sig_v4_clock_skew(amz_date: &AmzDate, now: time::OffsetDateTime, config: &S3Config) -> S3Result<()> { let request_time = amz_date.to_time().ok_or_else(|| invalid_request!("invalid amz date"))?; - let now = time::OffsetDateTime::now_utc(); let duration = now - request_time; - let max_skew_time = time::Duration::seconds(i64::from(config.snapshot().presigned_url_max_skew_time_secs)); + let max_skew_time = time::Duration::seconds(i64::from(config.presigned_url_max_skew_time_secs)); if duration.abs() > max_skew_time { return Err(s3_error!(RequestTimeTooSkewed, "request time is too far from server time")); @@ -267,7 +266,7 @@ impl SignatureContext<'_> { CredentialV4::parse(info.x_amz_credential).map_err(|_| invalid_request!("invalid field: x-amz-credential"))?; let amz_date = AmzDate::parse(info.x_amz_date).map_err(|_| invalid_request!("invalid field: x-amz-date"))?; - validate_sig_v4_clock_skew(&amz_date, self.config)?; + validate_sig_v4_clock_skew(&amz_date, time::OffsetDateTime::now_utc(), &self.config.snapshot())?; let access_key = credential.access_key_id.to_owned(); let secret_key = auth.get_secret_key(&access_key).await?; @@ -429,7 +428,7 @@ impl SignatureContext<'_> { let secret_key = auth.get_secret_key(access_key).await?; let amz_date = extract_amz_date(&self.hs)?.ok_or_else(|| invalid_request!("missing header: x-amz-date"))?; - validate_sig_v4_clock_skew(&amz_date, self.config)?; + validate_sig_v4_clock_skew(&amz_date, time::OffsetDateTime::now_utc(), &self.config.snapshot())?; let is_stream = amz_content_sha256.is_some_and(|v| v.is_streaming()); From 824d077308913bbde46496d301a143a1f570a90f Mon Sep 17 00:00:00 2001 From: Nugine Date: Sun, 21 Jun 2026 20:49:00 +0800 Subject: [PATCH 6/8] fix(s3s/sigv4): enforce credential scope date matches x-amz-date Per AWS SigV4 spec, the date in the credential scope must match the date in the x-amz-date header/form-field. Previously the verifier ignored the credential date; add explicit checks in all three SigV4 paths (header auth, POST signature, presigned URL). --- crates/s3s/src/ops/signature.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index 487d4598..12e1c8d0 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -266,6 +266,14 @@ impl SignatureContext<'_> { CredentialV4::parse(info.x_amz_credential).map_err(|_| invalid_request!("invalid field: x-amz-credential"))?; let amz_date = AmzDate::parse(info.x_amz_date).map_err(|_| invalid_request!("invalid field: x-amz-date"))?; + + // Per AWS SigV4 spec, the credential scope date must match the x-amz-date date. + if credential.date != amz_date.fmt_date().as_str() { + let mut err = S3Error::new(S3ErrorCode::SignatureDoesNotMatch); + err.set_message("credential scope date does not match x-amz-date"); + return Err(err); + } + validate_sig_v4_clock_skew(&amz_date, time::OffsetDateTime::now_utc(), &self.config.snapshot())?; let access_key = credential.access_key_id.to_owned(); @@ -315,6 +323,13 @@ impl SignatureContext<'_> { )); } + // Per AWS SigV4 spec, the credential scope date must match the x-amz-date date. + if presigned_url.credential.date != presigned_url.amz_date.fmt_date().as_str() { + let mut err = S3Error::new(S3ErrorCode::SignatureDoesNotMatch); + err.set_message("credential scope date does not match x-amz-date"); + return Err(err); + } + // ASK: how to use it? let _content_sha256: Option> = extract_amz_content_sha256(&self.hs)?; @@ -428,6 +443,14 @@ impl SignatureContext<'_> { let secret_key = auth.get_secret_key(access_key).await?; let amz_date = extract_amz_date(&self.hs)?.ok_or_else(|| invalid_request!("missing header: x-amz-date"))?; + + // Per AWS SigV4 spec, the credential scope date must match the x-amz-date date. + if authorization.credential.date != amz_date.fmt_date().as_str() { + let mut err = S3Error::new(S3ErrorCode::SignatureDoesNotMatch); + err.set_message("credential scope date does not match x-amz-date"); + return Err(err); + } + validate_sig_v4_clock_skew(&amz_date, time::OffsetDateTime::now_utc(), &self.config.snapshot())?; let is_stream = amz_content_sha256.is_some_and(|v| v.is_streaming()); From 43e4f3b924761f9059c695786e0ae233bcee454e Mon Sep 17 00:00:00 2001 From: Nugine Date: Sun, 21 Jun 2026 21:10:57 +0800 Subject: [PATCH 7/8] fix(s3s/sigv4): validate POST policy fields match sigV4 conditions Per AWS SigV4 spec, the signed POST policy must contain eq conditions for x-amz-date, x-amz-credential, and x-amz-algorithm that match the submitted form fields exactly. This closes a replay-window bypass where the clock-skew check trusted the unsigned form field x-amz-date while the signing key only bound YYYYMMDD. - Add PostPolicy::eq_condition_value helper - Validate policy conditions in v4_check_post_signature, before clock-skew check and signature verification - Update test fixtures to include required eq conditions --- crates/s3s/src/ops/signature.rs | 33 ++++++++++++++++++++++++++++- crates/s3s/src/ops/tests.rs | 37 ++++++++++++++++++++++++++------- crates/s3s/src/post_policy.rs | 9 ++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index 12e1c8d0..eb00e2dd 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -5,6 +5,7 @@ use crate::error::*; use crate::http; use crate::http::{AwsChunkedStream, Body, Multipart, MultipartLimits}; use crate::http::{OrderedHeaders, OrderedQs}; +use crate::post_policy::PostPolicy; use crate::protocol::TrailingHeaders; use crate::sig_v2; use crate::sig_v2::{AuthorizationV2, PostSignatureV2, PresignedUrlV2}; @@ -267,6 +268,28 @@ impl SignatureContext<'_> { let amz_date = AmzDate::parse(info.x_amz_date).map_err(|_| invalid_request!("invalid field: x-amz-date"))?; + // Per AWS SigV4 spec, the signed POST policy must contain eq conditions + // for x-amz-date, x-amz-credential, and x-amz-algorithm that match the + // submitted form fields exactly. + { + let policy = PostPolicy::from_base64(info.policy).map_err(|e| s3_error!(e, InvalidPolicyDocument))?; + + let policy_date = policy.eq_condition_value("x-amz-date"); + if policy_date != Some(info.x_amz_date) { + return Err(s3_error!(InvalidPolicyDocument, "x-amz-date does not match policy")); + } + + let policy_credential = policy.eq_condition_value("x-amz-credential"); + if policy_credential != Some(info.x_amz_credential) { + return Err(s3_error!(InvalidPolicyDocument, "x-amz-credential does not match policy")); + } + + let policy_algo = policy.eq_condition_value("x-amz-algorithm"); + if policy_algo != Some(info.x_amz_algorithm) { + return Err(s3_error!(InvalidPolicyDocument, "x-amz-algorithm does not match policy")); + } + } + // Per AWS SigV4 spec, the credential scope date must match the x-amz-date date. if credential.date != amz_date.fmt_date().as_str() { let mut err = S3Error::new(S3ErrorCode::SignatureDoesNotMatch); @@ -1586,7 +1609,15 @@ file content\r\n\ let request_time = time::OffsetDateTime::now_utc() - skew - time::Duration::minutes(1); let amz_date_str = fmt_current_amz_date(request_time); let amz_date = AmzDate::parse(&amz_date_str).unwrap(); - let policy_b64 = base64_simd::STANDARD.encode_to_string("{}"); + + // Construct a proper POST policy JSON with the required eq conditions + let policy_json = format!( + r#"{{"expiration":"2099-01-01T00:00:00Z","conditions":[{{"x-amz-date":"{amz_date}"}},{{"x-amz-credential":"{access_key}/{date}/us-east-1/s3/aws4_request"}},{{"x-amz-algorithm":"AWS4-HMAC-SHA256"}}]}}"#, + amz_date = amz_date_str, + access_key = access_key, + date = amz_date.fmt_date(), + ); + let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json); let signature = sig_v4::calculate_signature(&policy_b64, &secret_key, &amz_date, "us-east-1", "s3"); let boundary = "boundary123"; let body = format!( diff --git a/crates/s3s/src/ops/tests.rs b/crates/s3s/src/ops/tests.rs index 187de3d9..0945c05c 100644 --- a/crates/s3s/src/ops/tests.rs +++ b/crates/s3s/src/ops/tests.rs @@ -662,26 +662,45 @@ mod post_policy_test_helpers { ) } - /// Build a POST object request with a policy + /// Augment a test POST policy JSON with the required `SigV4` eq conditions + /// (`x-amz-date`, `x-amz-credential`, `x-amz-algorithm`) so the request + /// passes the verifier's policy-field-matching checks. + fn augment_post_policy_for_test(policy_json: &str, amz_date: &str, credential: &str, algorithm: &str) -> String { + let mut policy: serde_json::Value = serde_json::from_str(policy_json).expect("invalid test policy JSON"); + let conditions = policy["conditions"] + .as_array_mut() + .expect("policy must have a conditions array"); + conditions.push(serde_json::json!({"x-amz-date": amz_date})); + conditions.push(serde_json::json!({"x-amz-credential": credential})); + conditions.push(serde_json::json!({"x-amz-algorithm": algorithm})); + policy.to_string() + } + + /// Build a POST object request with a policy. + /// + /// The provided `policy_json` is augmented with the required `SigV4` POST + /// policy eq conditions (`x-amz-date`, `x-amz-credential`, `x-amz-algorithm`) + /// so the request passes the verifier's policy-field-matching checks. pub fn build_post_object_request( policy_json: &str, file_content: &str, secret_key: &SecretKey, with_content_type: bool, ) -> Request { - let policy_b64 = base64_simd::STANDARD.encode_to_string(policy_json); - let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); + let amz_date_str = amz_date.fmt_iso8601(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; + + let policy_json = augment_post_policy_for_test(policy_json, amz_date_str.as_str(), credential, algorithm); + let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json); let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); - let amz_date_str = amz_date.fmt_iso8601(); let fields = { let mut f = vec![ @@ -721,25 +740,29 @@ mod post_policy_test_helpers { /// This ensures that `aggregate_file_stream_limited` returns a `Vec` /// with multiple entries, so tests can distinguish between /// `vec_bytes.len()` (chunk count) and the total byte count. + /// + /// The provided `policy_json` is augmented with the required `SigV4` POST + /// policy eq conditions so the request passes the verifier's checks. pub fn build_post_object_request_chunked( policy_json: &str, file_content: &str, secret_key: &SecretKey, chunk_size: usize, ) -> Request { - let policy_b64 = base64_simd::STANDARD.encode_to_string(policy_json); - let boundary = "------------------------test12345678"; let bucket = "test-bucket"; let key = "test-key"; let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap(); + let amz_date_str = amz_date.fmt_iso8601(); let region = "us-east-1"; let service = "s3"; let content_type = "text/plain"; let algorithm = "AWS4-HMAC-SHA256"; let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request"; + + let policy_json = augment_post_policy_for_test(policy_json, amz_date_str.as_str(), credential, algorithm); + let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json); let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service); - let amz_date_str = amz_date.fmt_iso8601(); let body = build_multipart_fields( &[ diff --git a/crates/s3s/src/post_policy.rs b/crates/s3s/src/post_policy.rs index 0537271d..b3dcb5eb 100644 --- a/crates/s3s/src/post_policy.rs +++ b/crates/s3s/src/post_policy.rs @@ -257,6 +257,15 @@ impl PostPolicy { } None } + + /// Returns the value of an `eq` condition for the given field, if present. + #[must_use] + pub(crate) fn eq_condition_value(&self, field: &str) -> Option<&str> { + self.conditions.iter().find_map(|c| match c { + PostPolicyCondition::Eq { field: f, value } if f == field => Some(value.as_str()), + _ => None, + }) + } } /// Raw POST policy for deserialization From 70090fe3fd6d63a327e0af485f075103735d3c9b Mon Sep 17 00:00:00 2001 From: Nugine Date: Sun, 21 Jun 2026 21:47:21 +0800 Subject: [PATCH 8/8] fix(s3s/sigv4): reorder header-auth checks, use s3_error! macro, add TODO - Move x-amz-date extraction, credential-date validation, and clock-skew check before the secret key lookup in v4_check_header_auth so stale requests are rejected before I/O. - Replace remaining S3Error::new + set_message patterns in the new credential-date checks with the s3_error! macro for consistency. - Add TODO in v4_check_post_signature noting the double policy parse (signature verification + later prepare stage) as an optimization target. --- crates/s3s/src/ops/signature.rs | 39 +++++++++++++++------------------ 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index eb00e2dd..93ec02c3 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -37,10 +37,7 @@ fn extract_amz_content_sha256<'a>(hs: &'_ OrderedHeaders<'a>) -> S3Result