Skip to content

Commit 92966ae

Browse files
authored
Add expiry validation for S3 gateway requests (#9710)
This change adds validation for the `X-Amz-Expires` parameter in presigned URLs. Changes: - Parse `X-Amz-Expires` and validate within allowed range - Check expiration time for presigned URLs and deny request if past expiration time. - Add an explicit check for all required parameters for presigned URLs Fixes #9599
1 parent 6d73863 commit 92966ae

File tree

4 files changed

+596
-7
lines changed

4 files changed

+596
-7
lines changed

esti/presign_expiry_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
}

pkg/gateway/sig/sig.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import (
1515
)
1616

1717
var (
18-
ErrHeaderMalformed = errors.New("header malformed")
18+
ErrHeaderMalformed = errors.New("header malformed")
19+
ErrBadAuthorizationFormat = errors.New("authorization format not supported by this authenticator")
1920

2021
// reservedObjectNames - if object matches reserved string, no need to encode them
2122
reservedObjectNames = regexp.MustCompile("^[a-zA-Z0-9-_.~/]+$")
@@ -99,6 +100,11 @@ func (c *chainedAuthenticator) Parse() (SigContext, error) {
99100
if err == nil {
100101
c.chosen = method
101102
return sigContext, nil
103+
} else if !errors.Is(err, ErrHeaderMalformed) && !errors.Is(err, ErrBadAuthorizationFormat) {
104+
// ErrHeaderMalformed and ErrBadAuthorizationFormat indicate "wrong auth format, try next method".
105+
// All other errors mean the request matched this method's format but failed validation,
106+
// so return immediately without trying remaining methods.
107+
return nil, err
102108
}
103109
}
104110
return nil, gwErrors.ErrMissingFields

0 commit comments

Comments
 (0)