|
| 1 | +package esti |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "net/http" |
| 8 | + "net/url" |
| 9 | + "strconv" |
| 10 | + "strings" |
| 11 | + "testing" |
| 12 | + "time" |
| 13 | + |
| 14 | + "github.com/aws/aws-sdk-go-v2/aws" |
| 15 | + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" |
| 16 | + "github.com/minio/minio-go/v7" |
| 17 | + "github.com/minio/minio-go/v7/pkg/credentials" |
| 18 | + "github.com/spf13/viper" |
| 19 | + "github.com/stretchr/testify/require" |
| 20 | +) |
| 21 | + |
| 22 | +const ( |
| 23 | + // testS3Region is the AWS region used for S3 signature testing. |
| 24 | + // This must match the region used in testutil.SetupTestS3Client. |
| 25 | + testS3Region = "us-east-1" |
| 26 | +) |
| 27 | + |
| 28 | +// TestS3PresignedURLExpirationWithCustomTime tests presigned URL expiration validation |
| 29 | +// using the v4.Signer.PresignHTTP method which allows controlling the signing time |
| 30 | +func TestS3PresignedURLExpirationWithCustomTime(t *testing.T) { |
| 31 | + ctx, _, repo := setupTest(t) |
| 32 | + defer tearDownTest(repo) |
| 33 | + |
| 34 | + blockStoreType := viper.GetString(ViperBlockstoreType) |
| 35 | + if blockStoreType != "s3" { |
| 36 | + t.Skip("Skipping test - only S3 blockstore type is supported for presigned URL expiration validation") |
| 37 | + } |
| 38 | + |
| 39 | + // Upload a test file to use for presigned URL tests |
| 40 | + minioClient := newMinioClient(t, credentials.NewStaticV4) |
| 41 | + testContent := "test content for presigned URL expiration" |
| 42 | + testPath := "main/presign-expiry-test" |
| 43 | + |
| 44 | + _, err := minioClient.PutObject(ctx, repo, testPath, strings.NewReader(testContent), int64(len(testContent)), minio.PutObjectOptions{}) |
| 45 | + require.NoError(t, err, "failed to upload test file") |
| 46 | + |
| 47 | + accessKeyID := viper.GetString("access_key_id") |
| 48 | + secretAccessKey := viper.GetString("secret_access_key") |
| 49 | + s3Endpoint := viper.GetString("s3_endpoint") |
| 50 | + |
| 51 | + // Create v4 signer |
| 52 | + signer := v4.NewSigner() |
| 53 | + creds := aws.Credentials{ |
| 54 | + AccessKeyID: accessKeyID, |
| 55 | + SecretAccessKey: secretAccessKey, |
| 56 | + } |
| 57 | + |
| 58 | + // Helper function to create presigned URL with custom signing time |
| 59 | + createPresignedURL := func(signingTime time.Time, expiryDuration time.Duration) (string, error) { |
| 60 | + // Build the request URL properly using url.URL |
| 61 | + endpoint := s3Endpoint |
| 62 | + if !strings.HasPrefix(endpoint, "http") { |
| 63 | + if viper.GetBool("s3_endpoint_secure") { |
| 64 | + endpoint = "https://" + endpoint |
| 65 | + } else { |
| 66 | + endpoint = "http://" + endpoint |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + baseURL, err := url.Parse(endpoint) |
| 71 | + if err != nil { |
| 72 | + return "", fmt.Errorf("failed to parse endpoint: %w", err) |
| 73 | + } |
| 74 | + |
| 75 | + // Construct the path: /bucket/key |
| 76 | + baseURL.Path = fmt.Sprintf("/%s/%s", repo, testPath) |
| 77 | + |
| 78 | + // Create HTTP request |
| 79 | + req, err := http.NewRequest("GET", baseURL.String(), nil) |
| 80 | + if err != nil { |
| 81 | + return "", err |
| 82 | + } |
| 83 | + |
| 84 | + // Add required query parameters for presigning |
| 85 | + q := req.URL.Query() |
| 86 | + q.Set("X-Amz-Expires", strconv.FormatInt(int64(expiryDuration.Seconds()), 10)) |
| 87 | + req.URL.RawQuery = q.Encode() |
| 88 | + |
| 89 | + // Calculate payload hash (UNSIGNED-PAYLOAD for presigned URLs) |
| 90 | + payloadHash := "UNSIGNED-PAYLOAD" |
| 91 | + |
| 92 | + // Presign the request with custom signing time |
| 93 | + signedURI, _, err := signer.PresignHTTP( |
| 94 | + context.Background(), |
| 95 | + creds, |
| 96 | + req, |
| 97 | + payloadHash, |
| 98 | + "s3", |
| 99 | + testS3Region, |
| 100 | + signingTime, |
| 101 | + func(opts *v4.SignerOptions) { |
| 102 | + opts.DisableURIPathEscaping = true // S3 doesn't need additional escaping |
| 103 | + }, |
| 104 | + ) |
| 105 | + if err != nil { |
| 106 | + return "", err |
| 107 | + } |
| 108 | + |
| 109 | + return signedURI, nil |
| 110 | + } |
| 111 | + |
| 112 | + t.Run("valid_presigned_url", func(t *testing.T) { |
| 113 | + // Create a presigned URL signed now with 1 hour expiry |
| 114 | + presignedURL, err := createPresignedURL(time.Now(), time.Hour) |
| 115 | + require.NoError(t, err, "failed to create presigned URL") |
| 116 | + |
| 117 | + // The presigned URL should work |
| 118 | + resp, err := http.Get(presignedURL) |
| 119 | + require.NoError(t, err, "failed to GET presigned URL") |
| 120 | + defer resp.Body.Close() |
| 121 | + |
| 122 | + require.Equal(t, http.StatusOK, resp.StatusCode, "expected successful response from valid presigned URL") |
| 123 | + |
| 124 | + body, err := io.ReadAll(resp.Body) |
| 125 | + require.NoError(t, err, "failed to read response body") |
| 126 | + require.Equal(t, testContent, string(body), "content mismatch") |
| 127 | + }) |
| 128 | + |
| 129 | + t.Run("expired_presigned_url", func(t *testing.T) { |
| 130 | + // Create a presigned URL signed 15 minutes ago with 10 minute expiry |
| 131 | + // This means it expired 5 minutes ago |
| 132 | + signTime := time.Now().Add(-15 * time.Minute) |
| 133 | + presignedURL, err := createPresignedURL(signTime, 10*time.Minute) |
| 134 | + require.NoError(t, err, "failed to create presigned URL") |
| 135 | + |
| 136 | + // The presigned URL should be expired |
| 137 | + resp, err := http.Get(presignedURL) |
| 138 | + require.NoError(t, err, "failed to GET presigned URL") |
| 139 | + defer resp.Body.Close() |
| 140 | + |
| 141 | + require.Equal(t, http.StatusForbidden, resp.StatusCode, "expected 403 Forbidden from expired presigned URL") |
| 142 | + }) |
| 143 | + |
| 144 | + t.Run("presigned_url_within_clock_skew_tolerance", func(t *testing.T) { |
| 145 | + // Create a presigned URL signed 10 minutes in the future (within 15 minute tolerance) |
| 146 | + signTime := time.Now().Add(10 * time.Minute) |
| 147 | + presignedURL, err := createPresignedURL(signTime, time.Hour) |
| 148 | + require.NoError(t, err, "failed to create presigned URL") |
| 149 | + |
| 150 | + // The presigned URL should work despite being signed in the future |
| 151 | + resp, err := http.Get(presignedURL) |
| 152 | + require.NoError(t, err, "failed to GET presigned URL") |
| 153 | + defer resp.Body.Close() |
| 154 | + |
| 155 | + require.Equal(t, http.StatusOK, resp.StatusCode, |
| 156 | + "expected successful response from URL signed within 15min clock skew tolerance") |
| 157 | + |
| 158 | + body, err := io.ReadAll(resp.Body) |
| 159 | + require.NoError(t, err, "failed to read response body") |
| 160 | + require.Equal(t, testContent, string(body), "content mismatch") |
| 161 | + }) |
| 162 | + |
| 163 | + t.Run("presigned_url_signed_too_far_in_future", func(t *testing.T) { |
| 164 | + // Create a presigned URL signed 20 minutes in the future (beyond 15 minute tolerance) |
| 165 | + signTime := time.Now().Add(20 * time.Minute) |
| 166 | + presignedURL, err := createPresignedURL(signTime, time.Hour) |
| 167 | + require.NoError(t, err, "failed to create presigned URL") |
| 168 | + |
| 169 | + // The presigned URL should be rejected |
| 170 | + resp, err := http.Get(presignedURL) |
| 171 | + require.NoError(t, err, "failed to GET presigned URL") |
| 172 | + defer resp.Body.Close() |
| 173 | + |
| 174 | + require.Equal(t, http.StatusForbidden, resp.StatusCode, |
| 175 | + "expected 403 Forbidden from URL signed more than 15min in the future") |
| 176 | + }) |
| 177 | + |
| 178 | + t.Run("expiry_calculated_from_signed_time_not_current_time", func(t *testing.T) { |
| 179 | + // Test that expiry is calculated from signed time, not current time |
| 180 | + // This proves the expiration countdown starts when the URL is signed, |
| 181 | + // not when it's accessed. |
| 182 | + const ( |
| 183 | + timeInPast = 2 * time.Second // How long ago the URL was "signed" |
| 184 | + expiryDuration = 4 * time.Second // Total expiry time from signing |
| 185 | + sleepDuration = 3 * time.Second // How long to wait before second request |
| 186 | + ) |
| 187 | + // Expected behavior: |
| 188 | + // - First request: signed 2s ago + 4s expiry = 2s remaining → should work |
| 189 | + // - After 3s sleep: signed 5s ago + 4s expiry = expired 1s ago → should fail |
| 190 | + |
| 191 | + signTime := time.Now().Add(-timeInPast) |
| 192 | + presignedURL, err := createPresignedURL(signTime, expiryDuration) |
| 193 | + require.NoError(t, err, "failed to create presigned URL") |
| 194 | + |
| 195 | + // First request: URL should still be valid (2 seconds remaining) |
| 196 | + resp, err := http.Get(presignedURL) |
| 197 | + require.NoError(t, err, "failed to GET presigned URL") |
| 198 | + defer resp.Body.Close() |
| 199 | + |
| 200 | + require.Equal(t, http.StatusOK, resp.StatusCode, |
| 201 | + "expected successful response - URL should not be expired yet") |
| 202 | + |
| 203 | + body, err := io.ReadAll(resp.Body) |
| 204 | + require.NoError(t, err, "failed to read response body") |
| 205 | + require.Equal(t, testContent, string(body), "content mismatch") |
| 206 | + |
| 207 | + // Wait for the URL to expire |
| 208 | + time.Sleep(sleepDuration) |
| 209 | + |
| 210 | + // Second request: URL should now be expired (1 second past expiration) |
| 211 | + resp2, err := http.Get(presignedURL) |
| 212 | + require.NoError(t, err, "failed to GET presigned URL") |
| 213 | + defer resp2.Body.Close() |
| 214 | + |
| 215 | + require.Equal(t, http.StatusForbidden, resp2.StatusCode, |
| 216 | + "expected 403 Forbidden - URL should be expired after waiting") |
| 217 | + }) |
| 218 | +} |
0 commit comments