Skip to content

Commit abfd2cc

Browse files
CopilotNugineCopilot
authored
Add tests for PUT presigned URL signature verification (#402)
* Add tests for PUT presigned URL signature verification Co-authored-by: Nugine <30099658+Nugine@users.noreply.github.com> * Fix formatting in PUT presigned URL tests Co-authored-by: Nugine <30099658+Nugine@users.noreply.github.com> * Simplify PUT presigned URL tests to reduce duplication Co-authored-by: Nugine <30099658+Nugine@users.noreply.github.com> * Add e2e tests for PUT presigned URL support Co-authored-by: Nugine <30099658+Nugine@users.noreply.github.com> * fix * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * audit --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Nugine <30099658+Nugine@users.noreply.github.com> Co-authored-by: Nugine <nugine@foxmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b0d16a0 commit abfd2cc

File tree

5 files changed

+214
-1
lines changed

5 files changed

+214
-1
lines changed

.cargo/audit.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Project-level cargo-audit configuration
2+
# See: https://github.com/RustSec/rustsec/blob/main/cargo-audit/audit.toml.example
3+
4+
[advisories]
5+
# Ignored advisories with reasons:
6+
# RUSTSEC-2025-0134: Transitive dependency 'rustls-pemfile' is marked
7+
# unmaintained; accepted temporarily due to upstream dependency chain
8+
# (hyper-rustls via aws-smithy). Re-evaluate and remove when aws-* deps
9+
# update or the advisory is resolved upstream.
10+
ignore = ["RUSTSEC-2025-0134"]

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/s3s-e2e/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ bytes = "1.11.0"
2525
http-body = "1.0.1"
2626
md-5.workspace = true
2727
base64-simd = "0.8.0"
28+
reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls"] }
2829

2930
[dependencies.aws-config]
3031
version = "1.8.7"

crates/s3s-e2e/src/advanced.rs

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
use crate::case;
22

3-
use aws_sdk_s3::types::ChecksumAlgorithm;
43
use s3s_test::Result;
54
use s3s_test::TestFixture;
65
use s3s_test::TestSuite;
76
use s3s_test::tcx::TestContext;
87

98
use std::ops::Not;
109
use std::sync::Arc;
10+
use std::time::Duration;
1111

12+
use aws_sdk_s3::presigning::PresigningConfig;
1213
use aws_sdk_s3::primitives::ByteStream;
14+
use aws_sdk_s3::types::ChecksumAlgorithm;
15+
1316
use tracing::debug;
1417

1518
pub fn register(tcx: &mut TestContext) {
1619
case!(tcx, Advanced, STS, test_assume_role);
1720
case!(tcx, Advanced, Multipart, test_multipart_upload);
1821
case!(tcx, Advanced, Tagging, test_object_tagging);
1922
case!(tcx, Advanced, ListPagination, test_list_objects_with_pagination);
23+
case!(tcx, Advanced, PresignedUrl, test_put_presigned_url);
24+
case!(tcx, Advanced, PresignedUrl, test_get_presigned_url);
2025
}
2126

2227
struct Advanced {
@@ -371,3 +376,111 @@ impl ListPagination {
371376
Ok(())
372377
}
373378
}
379+
380+
struct PresignedUrl {
381+
s3: aws_sdk_s3::Client,
382+
bucket: String,
383+
key: String,
384+
}
385+
386+
impl TestFixture<Advanced> for PresignedUrl {
387+
async fn setup(suite: Arc<Advanced>) -> Result<Self> {
388+
use crate::utils::*;
389+
390+
let s3 = &suite.s3;
391+
let bucket = "test-presigned-url";
392+
let key = "presigned-file";
393+
394+
delete_object_loose(s3, bucket, key).await?;
395+
delete_bucket_loose(s3, bucket).await?;
396+
create_bucket(s3, bucket).await?;
397+
398+
Ok(Self {
399+
s3: suite.s3.clone(),
400+
bucket: bucket.to_owned(),
401+
key: key.to_owned(),
402+
})
403+
}
404+
405+
async fn teardown(self) -> Result {
406+
use crate::utils::*;
407+
408+
let Self { s3, bucket, key } = &self;
409+
delete_object_loose(s3, bucket, key).await?;
410+
delete_bucket_loose(s3, bucket).await?;
411+
Ok(())
412+
}
413+
}
414+
415+
impl PresignedUrl {
416+
/// Test PUT presigned URL - upload an object using a presigned URL
417+
async fn test_put_presigned_url(self: Arc<Self>) -> Result<()> {
418+
let s3 = &self.s3;
419+
let bucket = self.bucket.as_str();
420+
let key = self.key.as_str();
421+
422+
let content = "Hello from PUT presigned URL!";
423+
424+
// Create a presigned PUT URL
425+
let presigning_config = PresigningConfig::expires_in(Duration::from_secs(3600))?;
426+
let presigned_request = s3.put_object().bucket(bucket).key(key).presigned(presigning_config).await?;
427+
428+
debug!(uri = %presigned_request.uri(), "PUT presigned URL created");
429+
430+
// Use reqwest to upload content via the presigned URL
431+
let client = reqwest::Client::new();
432+
let response = client.put(presigned_request.uri()).body(content).send().await?;
433+
434+
assert!(
435+
response.status().is_success(),
436+
"PUT presigned URL request failed: {:?}",
437+
response.status()
438+
);
439+
440+
// Verify the object was uploaded correctly by reading it back
441+
let resp = s3.get_object().bucket(bucket).key(key).send().await?;
442+
let body = resp.body.collect().await?;
443+
let body = String::from_utf8(body.to_vec())?;
444+
assert_eq!(body, content);
445+
446+
Ok(())
447+
}
448+
449+
/// Test GET presigned URL - download an object using a presigned URL
450+
async fn test_get_presigned_url(self: Arc<Self>) -> Result<()> {
451+
let s3 = &self.s3;
452+
let bucket = self.bucket.as_str();
453+
let key = self.key.as_str();
454+
455+
let content = "Hello from GET presigned URL!";
456+
457+
// First, upload an object using the regular SDK
458+
s3.put_object()
459+
.bucket(bucket)
460+
.key(key)
461+
.body(ByteStream::from_static(content.as_bytes()))
462+
.send()
463+
.await?;
464+
465+
// Create a presigned GET URL
466+
let presigning_config = PresigningConfig::expires_in(Duration::from_secs(3600))?;
467+
let presigned_request = s3.get_object().bucket(bucket).key(key).presigned(presigning_config).await?;
468+
469+
debug!(uri = %presigned_request.uri(), "GET presigned URL created");
470+
471+
// Use reqwest to download content via the presigned URL
472+
let client = reqwest::Client::new();
473+
let response = client.get(presigned_request.uri()).send().await?;
474+
475+
assert!(
476+
response.status().is_success(),
477+
"GET presigned URL request failed: {:?}",
478+
response.status()
479+
);
480+
481+
let body = response.text().await?;
482+
assert_eq!(body, content);
483+
484+
Ok(())
485+
}
486+
}

crates/s3s/src/sig_v4/methods.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,94 @@ mod tests {
12921292
assert_eq!(ans, "");
12931293
}
12941294

1295+
#[test]
1296+
fn example_put_presigned_url() {
1297+
// Test PUT presigned URL signing - similar to GET but with PUT method
1298+
// This is used for uploading files to S3 using presigned URLs
1299+
// Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
1300+
let secret_access_key = SecretKey::from("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
1301+
let method = Method::PUT;
1302+
let headers = OrderedHeaders::from_slice_unchecked(&[("host", "examplebucket.s3.amazonaws.com")]);
1303+
1304+
// Query strings for signing (without signature - signature is computed from these)
1305+
let query_strings_for_signing = &[
1306+
("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
1307+
("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
1308+
("X-Amz-Date", "20130524T000000Z"),
1309+
("X-Amz-Expires", "86400"),
1310+
("X-Amz-SignedHeaders", "host"),
1311+
];
1312+
1313+
let canonical_request = create_presigned_canonical_request(&method, "/test.txt", query_strings_for_signing, &headers);
1314+
1315+
// Canonical request for PUT should be similar to GET, just with PUT method
1316+
assert_eq!(
1317+
canonical_request,
1318+
concat!(
1319+
"PUT\n",
1320+
"/test.txt\n",
1321+
"X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host\n",
1322+
"host:examplebucket.s3.amazonaws.com\n",
1323+
"\n",
1324+
"host\n",
1325+
"UNSIGNED-PAYLOAD",
1326+
)
1327+
);
1328+
1329+
let amz_date = AmzDate::parse("20130524T000000Z").unwrap();
1330+
let string_to_sign = create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3");
1331+
let signature = calculate_signature(&string_to_sign, &secret_access_key, &amz_date, "us-east-1", "s3");
1332+
1333+
// Signature value derived from the above test inputs (not from official AWS test vectors)
1334+
assert_eq!(signature, "f4db56459304dafaa603a99a23c6bea8821890259a65c18ff503a4a72a80efd9");
1335+
}
1336+
1337+
#[test]
1338+
fn example_put_presigned_url_with_content_type() {
1339+
// Test PUT presigned URL with content-type signed header
1340+
// When content-type is in signed headers, it must match the request header exactly
1341+
let secret_access_key = SecretKey::from("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
1342+
let method = Method::PUT;
1343+
1344+
// Headers include content-type which is signed
1345+
let headers = OrderedHeaders::from_slice_unchecked(&[
1346+
("content-type", "application/octet-stream"),
1347+
("host", "examplebucket.s3.amazonaws.com"),
1348+
]);
1349+
1350+
let query_strings_for_signing = &[
1351+
("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
1352+
("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
1353+
("X-Amz-Date", "20130524T000000Z"),
1354+
("X-Amz-Expires", "86400"),
1355+
("X-Amz-SignedHeaders", "content-type;host"),
1356+
];
1357+
1358+
let canonical_request = create_presigned_canonical_request(&method, "/test.txt", query_strings_for_signing, &headers);
1359+
1360+
// Canonical request should include content-type header
1361+
assert_eq!(
1362+
canonical_request,
1363+
concat!(
1364+
"PUT\n",
1365+
"/test.txt\n",
1366+
"X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=content-type%3Bhost\n",
1367+
"content-type:application/octet-stream\n",
1368+
"host:examplebucket.s3.amazonaws.com\n",
1369+
"\n",
1370+
"content-type;host\n",
1371+
"UNSIGNED-PAYLOAD",
1372+
)
1373+
);
1374+
1375+
let amz_date = AmzDate::parse("20130524T000000Z").unwrap();
1376+
let string_to_sign = create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3");
1377+
let signature = calculate_signature(&string_to_sign, &secret_access_key, &amz_date, "us-east-1", "s3");
1378+
1379+
// Signature value derived from the test inputs above; not from official AWS test vectors.
1380+
assert_eq!(signature, "fd31b71961609f4b313497cb07ab0aedd268863bd547cc198db23cf04b8f663d");
1381+
}
1382+
12951383
#[test]
12961384
fn multi_value_headers_combined_with_comma() {
12971385
// Test that multiple headers with the same name are combined into a single line

0 commit comments

Comments
 (0)