Unverified Commit abfd2cc0 authored by Copilot's avatar Copilot Committed by GitHub
Browse files

Add tests for PUT presigned URL signature verification (#402)



* Add tests for PUT presigned URL signature verification

Co-authored-by: default avatarNugine <30099658+Nugine@users.noreply.github.com>

* Fix formatting in PUT presigned URL tests

Co-authored-by: default avatarNugine <30099658+Nugine@users.noreply.github.com>

* Simplify PUT presigned URL tests to reduce duplication

Co-authored-by: default avatarNugine <30099658+Nugine@users.noreply.github.com>

* Add e2e tests for PUT presigned URL support

Co-authored-by: default avatarNugine <30099658+Nugine@users.noreply.github.com>

* fix

* Apply suggestions from code review

Co-authored-by: default avatarCopilot <175728472+Copilot@users.noreply.github.com>

* audit

---------

Co-authored-by: default avatarcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: default avatarNugine <30099658+Nugine@users.noreply.github.com>
Co-authored-by: default avatarNugine <nugine@foxmail.com>
Co-authored-by: default avatarCopilot <175728472+Copilot@users.noreply.github.com>
parent b0d16a00
Loading
Loading
Loading
Loading

.cargo/audit.toml

0 → 100644
+10 −0
Original line number Diff line number Diff line
# Project-level cargo-audit configuration
# See: https://github.com/RustSec/rustsec/blob/main/cargo-audit/audit.toml.example

[advisories]
# Ignored advisories with reasons:
# RUSTSEC-2025-0134: Transitive dependency 'rustls-pemfile' is marked
# unmaintained; accepted temporarily due to upstream dependency chain
# (hyper-rustls via aws-smithy). Re-evaluate and remove when aws-* deps
# update or the advisory is resolved upstream.
ignore = ["RUSTSEC-2025-0134"]
+1 −0
Original line number Diff line number Diff line
@@ -2932,6 +2932,7 @@ dependencies = [
 "http-body 1.0.1",
 "http-body-util",
 "md-5 0.11.0-rc.3",
 "reqwest",
 "s3s-test",
 "tracing",
]
+1 −0
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ bytes = "1.11.0"
http-body = "1.0.1"
md-5.workspace = true
base64-simd = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls"] }

[dependencies.aws-config]
version = "1.8.7"
+114 −1
Original line number Diff line number Diff line
use crate::case;

use aws_sdk_s3::types::ChecksumAlgorithm;
use s3s_test::Result;
use s3s_test::TestFixture;
use s3s_test::TestSuite;
@@ -8,8 +7,12 @@ use s3s_test::tcx::TestContext;

use std::ops::Not;
use std::sync::Arc;
use std::time::Duration;

use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ChecksumAlgorithm;

use tracing::debug;

pub fn register(tcx: &mut TestContext) {
@@ -17,6 +20,8 @@ pub fn register(tcx: &mut TestContext) {
    case!(tcx, Advanced, Multipart, test_multipart_upload);
    case!(tcx, Advanced, Tagging, test_object_tagging);
    case!(tcx, Advanced, ListPagination, test_list_objects_with_pagination);
    case!(tcx, Advanced, PresignedUrl, test_put_presigned_url);
    case!(tcx, Advanced, PresignedUrl, test_get_presigned_url);
}

struct Advanced {
@@ -371,3 +376,111 @@ impl ListPagination {
        Ok(())
    }
}

struct PresignedUrl {
    s3: aws_sdk_s3::Client,
    bucket: String,
    key: String,
}

impl TestFixture<Advanced> for PresignedUrl {
    async fn setup(suite: Arc<Advanced>) -> Result<Self> {
        use crate::utils::*;

        let s3 = &suite.s3;
        let bucket = "test-presigned-url";
        let key = "presigned-file";

        delete_object_loose(s3, bucket, key).await?;
        delete_bucket_loose(s3, bucket).await?;
        create_bucket(s3, bucket).await?;

        Ok(Self {
            s3: suite.s3.clone(),
            bucket: bucket.to_owned(),
            key: key.to_owned(),
        })
    }

    async fn teardown(self) -> Result {
        use crate::utils::*;

        let Self { s3, bucket, key } = &self;
        delete_object_loose(s3, bucket, key).await?;
        delete_bucket_loose(s3, bucket).await?;
        Ok(())
    }
}

impl PresignedUrl {
    /// Test PUT presigned URL - upload an object using a presigned URL
    async fn test_put_presigned_url(self: Arc<Self>) -> Result<()> {
        let s3 = &self.s3;
        let bucket = self.bucket.as_str();
        let key = self.key.as_str();

        let content = "Hello from PUT presigned URL!";

        // Create a presigned PUT URL
        let presigning_config = PresigningConfig::expires_in(Duration::from_secs(3600))?;
        let presigned_request = s3.put_object().bucket(bucket).key(key).presigned(presigning_config).await?;

        debug!(uri = %presigned_request.uri(), "PUT presigned URL created");

        // Use reqwest to upload content via the presigned URL
        let client = reqwest::Client::new();
        let response = client.put(presigned_request.uri()).body(content).send().await?;

        assert!(
            response.status().is_success(),
            "PUT presigned URL request failed: {:?}",
            response.status()
        );

        // Verify the object was uploaded correctly by reading it back
        let resp = s3.get_object().bucket(bucket).key(key).send().await?;
        let body = resp.body.collect().await?;
        let body = String::from_utf8(body.to_vec())?;
        assert_eq!(body, content);

        Ok(())
    }

    /// Test GET presigned URL - download an object using a presigned URL
    async fn test_get_presigned_url(self: Arc<Self>) -> Result<()> {
        let s3 = &self.s3;
        let bucket = self.bucket.as_str();
        let key = self.key.as_str();

        let content = "Hello from GET presigned URL!";

        // First, upload an object using the regular SDK
        s3.put_object()
            .bucket(bucket)
            .key(key)
            .body(ByteStream::from_static(content.as_bytes()))
            .send()
            .await?;

        // Create a presigned GET URL
        let presigning_config = PresigningConfig::expires_in(Duration::from_secs(3600))?;
        let presigned_request = s3.get_object().bucket(bucket).key(key).presigned(presigning_config).await?;

        debug!(uri = %presigned_request.uri(), "GET presigned URL created");

        // Use reqwest to download content via the presigned URL
        let client = reqwest::Client::new();
        let response = client.get(presigned_request.uri()).send().await?;

        assert!(
            response.status().is_success(),
            "GET presigned URL request failed: {:?}",
            response.status()
        );

        let body = response.text().await?;
        assert_eq!(body, content);

        Ok(())
    }
}
+88 −0
Original line number Diff line number Diff line
@@ -1292,6 +1292,94 @@ mod tests {
        assert_eq!(ans, "");
    }

    #[test]
    fn example_put_presigned_url() {
        // Test PUT presigned URL signing - similar to GET but with PUT method
        // This is used for uploading files to S3 using presigned URLs
        // Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
        let secret_access_key = SecretKey::from("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
        let method = Method::PUT;
        let headers = OrderedHeaders::from_slice_unchecked(&[("host", "examplebucket.s3.amazonaws.com")]);

        // Query strings for signing (without signature - signature is computed from these)
        let query_strings_for_signing = &[
            ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
            ("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
            ("X-Amz-Date", "20130524T000000Z"),
            ("X-Amz-Expires", "86400"),
            ("X-Amz-SignedHeaders", "host"),
        ];

        let canonical_request = create_presigned_canonical_request(&method, "/test.txt", query_strings_for_signing, &headers);

        // Canonical request for PUT should be similar to GET, just with PUT method
        assert_eq!(
            canonical_request,
            concat!(
                "PUT\n",
                "/test.txt\n",
                "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",
                "host:examplebucket.s3.amazonaws.com\n",
                "\n",
                "host\n",
                "UNSIGNED-PAYLOAD",
            )
        );

        let amz_date = AmzDate::parse("20130524T000000Z").unwrap();
        let string_to_sign = create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3");
        let signature = calculate_signature(&string_to_sign, &secret_access_key, &amz_date, "us-east-1", "s3");

        // Signature value derived from the above test inputs (not from official AWS test vectors)
        assert_eq!(signature, "f4db56459304dafaa603a99a23c6bea8821890259a65c18ff503a4a72a80efd9");
    }

    #[test]
    fn example_put_presigned_url_with_content_type() {
        // Test PUT presigned URL with content-type signed header
        // When content-type is in signed headers, it must match the request header exactly
        let secret_access_key = SecretKey::from("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
        let method = Method::PUT;

        // Headers include content-type which is signed
        let headers = OrderedHeaders::from_slice_unchecked(&[
            ("content-type", "application/octet-stream"),
            ("host", "examplebucket.s3.amazonaws.com"),
        ]);

        let query_strings_for_signing = &[
            ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
            ("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"),
            ("X-Amz-Date", "20130524T000000Z"),
            ("X-Amz-Expires", "86400"),
            ("X-Amz-SignedHeaders", "content-type;host"),
        ];

        let canonical_request = create_presigned_canonical_request(&method, "/test.txt", query_strings_for_signing, &headers);

        // Canonical request should include content-type header
        assert_eq!(
            canonical_request,
            concat!(
                "PUT\n",
                "/test.txt\n",
                "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",
                "content-type:application/octet-stream\n",
                "host:examplebucket.s3.amazonaws.com\n",
                "\n",
                "content-type;host\n",
                "UNSIGNED-PAYLOAD",
            )
        );

        let amz_date = AmzDate::parse("20130524T000000Z").unwrap();
        let string_to_sign = create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3");
        let signature = calculate_signature(&string_to_sign, &secret_access_key, &amz_date, "us-east-1", "s3");

        // Signature value derived from the test inputs above; not from official AWS test vectors.
        assert_eq!(signature, "fd31b71961609f4b313497cb07ab0aedd268863bd547cc198db23cf04b8f663d");
    }

    #[test]
    fn multi_value_headers_combined_with_comma() {
        // Test that multiple headers with the same name are combined into a single line