Skip to content

Commit 095afbd

Browse files
committed
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
1 parent 824d077 commit 095afbd

3 files changed

Lines changed: 72 additions & 8 deletions

File tree

crates/s3s/src/ops/signature.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::error::*;
55
use crate::http;
66
use crate::http::{AwsChunkedStream, Body, Multipart, MultipartLimits};
77
use crate::http::{OrderedHeaders, OrderedQs};
8+
use crate::post_policy::PostPolicy;
89
use crate::protocol::TrailingHeaders;
910
use crate::sig_v2;
1011
use crate::sig_v2::{AuthorizationV2, PostSignatureV2, PresignedUrlV2};
@@ -267,6 +268,29 @@ impl SignatureContext<'_> {
267268

268269
let amz_date = AmzDate::parse(info.x_amz_date).map_err(|_| invalid_request!("invalid field: x-amz-date"))?;
269270

271+
// Per AWS SigV4 spec, the signed POST policy must contain eq conditions
272+
// for x-amz-date, x-amz-credential, and x-amz-algorithm that match the
273+
// submitted form fields exactly.
274+
{
275+
let policy = PostPolicy::from_base64(info.policy)
276+
.map_err(|e| s3_error!(e, InvalidPolicyDocument))?;
277+
278+
let policy_date = policy.eq_condition_value("x-amz-date");
279+
if policy_date != Some(info.x_amz_date) {
280+
return Err(s3_error!(InvalidPolicyDocument, "x-amz-date does not match policy"));
281+
}
282+
283+
let policy_credential = policy.eq_condition_value("x-amz-credential");
284+
if policy_credential != Some(info.x_amz_credential) {
285+
return Err(s3_error!(InvalidPolicyDocument, "x-amz-credential does not match policy"));
286+
}
287+
288+
let policy_algo = policy.eq_condition_value("x-amz-algorithm");
289+
if policy_algo != Some(info.x_amz_algorithm) {
290+
return Err(s3_error!(InvalidPolicyDocument, "x-amz-algorithm does not match policy"));
291+
}
292+
}
293+
270294
// Per AWS SigV4 spec, the credential scope date must match the x-amz-date date.
271295
if credential.date != amz_date.fmt_date().as_str() {
272296
let mut err = S3Error::new(S3ErrorCode::SignatureDoesNotMatch);
@@ -1586,7 +1610,15 @@ file content\r\n\
15861610
let request_time = time::OffsetDateTime::now_utc() - skew - time::Duration::minutes(1);
15871611
let amz_date_str = fmt_current_amz_date(request_time);
15881612
let amz_date = AmzDate::parse(&amz_date_str).unwrap();
1589-
let policy_b64 = base64_simd::STANDARD.encode_to_string("{}");
1613+
1614+
// Construct a proper POST policy JSON with the required eq conditions
1615+
let policy_json = format!(
1616+
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"}}]}}"#,
1617+
amz_date = amz_date_str,
1618+
access_key = access_key,
1619+
date = amz_date.fmt_date(),
1620+
);
1621+
let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json);
15901622
let signature = sig_v4::calculate_signature(&policy_b64, &secret_key, &amz_date, "us-east-1", "s3");
15911623
let boundary = "boundary123";
15921624
let body = format!(

crates/s3s/src/ops/tests.rs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -662,26 +662,45 @@ mod post_policy_test_helpers {
662662
)
663663
}
664664

665-
/// Build a POST object request with a policy
665+
/// Augment a test POST policy JSON with the required `SigV4` eq conditions
666+
/// (`x-amz-date`, `x-amz-credential`, `x-amz-algorithm`) so the request
667+
/// passes the verifier's policy-field-matching checks.
668+
fn augment_post_policy_for_test(policy_json: &str, amz_date: &str, credential: &str, algorithm: &str) -> String {
669+
let mut policy: serde_json::Value = serde_json::from_str(policy_json).expect("invalid test policy JSON");
670+
let conditions = policy["conditions"]
671+
.as_array_mut()
672+
.expect("policy must have a conditions array");
673+
conditions.push(serde_json::json!({"x-amz-date": amz_date}));
674+
conditions.push(serde_json::json!({"x-amz-credential": credential}));
675+
conditions.push(serde_json::json!({"x-amz-algorithm": algorithm}));
676+
policy.to_string()
677+
}
678+
679+
/// Build a POST object request with a policy.
680+
///
681+
/// The provided `policy_json` is augmented with the required `SigV4` POST
682+
/// policy eq conditions (`x-amz-date`, `x-amz-credential`, `x-amz-algorithm`)
683+
/// so the request passes the verifier's policy-field-matching checks.
666684
pub fn build_post_object_request(
667685
policy_json: &str,
668686
file_content: &str,
669687
secret_key: &SecretKey,
670688
with_content_type: bool,
671689
) -> Request {
672-
let policy_b64 = base64_simd::STANDARD.encode_to_string(policy_json);
673-
674690
let boundary = "------------------------test12345678";
675691
let bucket = "test-bucket";
676692
let key = "test-key";
677693
let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap();
694+
let amz_date_str = amz_date.fmt_iso8601();
678695
let region = "us-east-1";
679696
let service = "s3";
680697
let content_type = "text/plain";
681698
let algorithm = "AWS4-HMAC-SHA256";
682699
let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request";
700+
701+
let policy_json = augment_post_policy_for_test(policy_json, amz_date_str.as_str(), credential, algorithm);
702+
let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json);
683703
let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service);
684-
let amz_date_str = amz_date.fmt_iso8601();
685704

686705
let fields = {
687706
let mut f = vec![
@@ -721,25 +740,29 @@ mod post_policy_test_helpers {
721740
/// This ensures that `aggregate_file_stream_limited` returns a `Vec<Bytes>`
722741
/// with multiple entries, so tests can distinguish between
723742
/// `vec_bytes.len()` (chunk count) and the total byte count.
743+
///
744+
/// The provided `policy_json` is augmented with the required `SigV4` POST
745+
/// policy eq conditions so the request passes the verifier's checks.
724746
pub fn build_post_object_request_chunked(
725747
policy_json: &str,
726748
file_content: &str,
727749
secret_key: &SecretKey,
728750
chunk_size: usize,
729751
) -> Request {
730-
let policy_b64 = base64_simd::STANDARD.encode_to_string(policy_json);
731-
732752
let boundary = "------------------------test12345678";
733753
let bucket = "test-bucket";
734754
let key = "test-key";
735755
let amz_date = sig_v4::AmzDate::parse("20250101T000000Z").unwrap();
756+
let amz_date_str = amz_date.fmt_iso8601();
736757
let region = "us-east-1";
737758
let service = "s3";
738759
let content_type = "text/plain";
739760
let algorithm = "AWS4-HMAC-SHA256";
740761
let credential = "AKIAIOSFODNN7EXAMPLE/20250101/us-east-1/s3/aws4_request";
762+
763+
let policy_json = augment_post_policy_for_test(policy_json, amz_date_str.as_str(), credential, algorithm);
764+
let policy_b64 = base64_simd::STANDARD.encode_to_string(&policy_json);
741765
let signature = sig_v4::calculate_signature(&policy_b64, secret_key, &amz_date, region, service);
742-
let amz_date_str = amz_date.fmt_iso8601();
743766

744767
let body = build_multipart_fields(
745768
&[

crates/s3s/src/post_policy.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ impl PostPolicy {
257257
}
258258
None
259259
}
260+
261+
/// Returns the value of an `eq` condition for the given field, if present.
262+
#[must_use]
263+
pub(crate) fn eq_condition_value(&self, field: &str) -> Option<&str> {
264+
self.conditions.iter().find_map(|c| match c {
265+
PostPolicyCondition::Eq { field: f, value } if f == field => Some(value.as_str()),
266+
_ => None,
267+
})
268+
}
260269
}
261270

262271
/// Raw POST policy for deserialization

0 commit comments

Comments
 (0)