Skip to content

Commit ae1f8fd

Browse files
Fix API parameters signature
1 parent 60a591d commit ae1f8fd

9 files changed

Lines changed: 316 additions & 8 deletions

File tree

api/api.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,33 @@ func BuildPath(parts ...interface{}) string {
295295
return strings.Join(partsSlice, "/")
296296
}
297297

298-
// SignParametersUsingAlgo signs parameters using the provided secret and sign algorithm.
299-
func SignParametersUsingAlgo(params url.Values, secret string, algo signature.Algo) (string, error) {
298+
// encodeParam encodes a parameter for safe inclusion in URL query strings.
299+
//
300+
// Specifically replaces "&" characters with their percent-encoded equivalent "%26"
301+
// to prevent them from being interpreted as parameter separators in URL query strings.
302+
// This encoding is only applied when signatureVersion is 2 or higher.
303+
func encodeParam(value string, signatureVersion int) string {
304+
// Version 2: URL encode & characters in values to prevent parameter smuggling
305+
if signatureVersion >= 2 {
306+
return strings.Replace(value, "&", "%26", -1)
307+
}
308+
return value
309+
}
310+
311+
// SignParametersUsingAlgoAndVersion signs parameters using the provided secret, sign algorithm, and signature version.
312+
func SignParametersUsingAlgoAndVersion(params url.Values, secret string, algo signature.Algo, signatureVersion int) (string, error) {
300313
if _, withTimestamp := params["timestamp"]; !withTimestamp || params["timestamp"][0] == "0" {
301314
params.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
302315
}
303316

304-
encodedUnescapedParams, err := url.QueryUnescape(params.Encode())
317+
encodedParams := make(url.Values)
318+
for key, values := range params {
319+
for _, value := range values {
320+
encodedParams.Add(key, encodeParam(value, signatureVersion))
321+
}
322+
}
323+
324+
encodedUnescapedParams, err := url.QueryUnescape(encodedParams.Encode())
305325
if err != nil {
306326
return "", err
307327
}
@@ -313,6 +333,11 @@ func SignParametersUsingAlgo(params url.Values, secret string, algo signature.Al
313333
return hex.EncodeToString(rawSignature), nil
314334
}
315335

336+
// SignParametersUsingAlgo signs parameters using the provided secret and sign algorithm.
337+
func SignParametersUsingAlgo(params url.Values, secret string, algo signature.Algo) (string, error) {
338+
return SignParametersUsingAlgoAndVersion(params, secret, algo, 2)
339+
}
340+
316341
// SignParameters signs parameters using the provided secret.
317342
func SignParameters(params url.Values, secret string) (string, error) {
318343
return SignParametersUsingAlgo(params, secret, signature.SHA1)

api/api_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api_test
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/cloudinary/cloudinary-go/v2/api"
8+
"github.com/cloudinary/cloudinary-go/v2/internal/signature"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// TestAPISignRequestPreventsParameterSmuggling tests that parameter smuggling via & characters is prevented.
13+
// Should prevent parameter smuggling via & characters in parameter values.
14+
func TestAPISignRequestPreventsParameterSmuggling(t *testing.T) {
15+
const testSecret = "hdcixPpR2iKERPwqvH6sHdK9cyac"
16+
17+
// Test with notification_url containing & characters
18+
paramsWithAmpersand := url.Values{}
19+
paramsWithAmpersand.Set("cloud_name", "dn6ot3ged")
20+
paramsWithAmpersand.Set("timestamp", "1568810420")
21+
paramsWithAmpersand.Set("notification_url", "https://fake.com/callback?a=1&tags=hello,world")
22+
23+
signatureWithAmpersand, err := api.SignParametersUsingAlgo(paramsWithAmpersand, testSecret, signature.SHA1)
24+
assert.NoError(t, err)
25+
26+
// Test that attempting to smuggle parameters by splitting the notification_url fails
27+
paramsSmuggled := url.Values{}
28+
paramsSmuggled.Set("cloud_name", "dn6ot3ged")
29+
paramsSmuggled.Set("timestamp", "1568810420")
30+
paramsSmuggled.Set("notification_url", "https://fake.com/callback?a=1")
31+
paramsSmuggled.Set("tags", "hello,world") // This would be smuggled if & encoding didn't work
32+
33+
signatureSmuggled, err := api.SignParametersUsingAlgo(paramsSmuggled, testSecret, signature.SHA1)
34+
assert.NoError(t, err)
35+
36+
// The signatures should be different, proving that parameter smuggling is prevented
37+
assert.NotEqual(t, signatureWithAmpersand, signatureSmuggled,
38+
"Signatures should be different to prevent parameter smuggling")
39+
40+
// Verify the expected signature for the properly encoded case
41+
const expectedSignature = "4fdf465dd89451cc1ed8ec5b3e314e8a51695704"
42+
assert.Equal(t, expectedSignature, signatureWithAmpersand)
43+
44+
// Verify the expected signature for the smuggled parameters case
45+
const expectedSmuggledSignature = "7b4e3a539ff1fa6e6700c41b3a2ee77586a025f9"
46+
assert.Equal(t, expectedSmuggledSignature, signatureSmuggled)
47+
}
48+
49+
// TestConfiguredSignatureVersionIsApplied tests that the configured signature version is applied.
50+
func TestConfiguredSignatureVersionIsApplied(t *testing.T) {
51+
const testSecret = "hdcixPpR2iKERPwqvH6sHdK9cyac"
52+
53+
params := url.Values{}
54+
params.Set("cloud_name", "dn6ot3ged")
55+
params.Set("timestamp", "1568810420")
56+
params.Set("notification_url", "https://fake.com/callback?a=1&tags=hello,world")
57+
58+
// Test version 1 (no encoding)
59+
signatureV1, err := api.SignParametersUsingAlgoAndVersion(params, testSecret, signature.SHA1, 1)
60+
assert.NoError(t, err)
61+
62+
// Test version 2 (with encoding)
63+
signatureV2, err := api.SignParametersUsingAlgoAndVersion(params, testSecret, signature.SHA1, 2)
64+
assert.NoError(t, err)
65+
66+
// Should be different
67+
assert.NotEqual(t, signatureV1, signatureV2, "Signatures should be different between versions")
68+
69+
// Version 2 should match expected signature from smuggling test
70+
assert.Equal(t, "4fdf465dd89451cc1ed8ec5b3e314e8a51695704", signatureV2,
71+
"Version 2 should match expected encoded signature")
72+
}

api/uploader/setup_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package uploader_test
2+
3+
import "github.com/cloudinary/cloudinary-go/v2/api/uploader"
4+
5+
var uploadAPI, _ = uploader.New()

api/uploader/upload.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
"github.com/cloudinary/cloudinary-go/v2/api"
2929
"github.com/cloudinary/cloudinary-go/v2/config"
30+
"github.com/cloudinary/cloudinary-go/v2/internal/signature"
3031
"github.com/google/uuid"
3132

3233
"github.com/cloudinary/cloudinary-go/v2/logger"
@@ -134,8 +135,8 @@ func (u *API) signRequest(requestParams url.Values) (url.Values, error) {
134135
signatureParams[k] = []string{strings.Join(v, ",")}
135136
}
136137

137-
signature, err := api.SignParametersUsingAlgo(signatureParams, u.Config.Cloud.APISecret,
138-
u.Config.Cloud.GetSignatureAlgorithm())
138+
signature, err := api.SignParametersUsingAlgoAndVersion(signatureParams, u.Config.Cloud.APISecret,
139+
u.Config.Cloud.GetSignatureAlgorithm(), u.Config.Cloud.GetSignatureVersion())
139140
if err != nil {
140141
return nil, err
141142
}
@@ -395,3 +396,43 @@ func min(a, b int64) int64 {
395396
}
396397
return b
397398
}
399+
400+
// VerifyApiResponseSignature validates API response signature against Cloudinary configuration.
401+
// It validates that the response came from Cloudinary by checking the signature.
402+
func (u *API) VerifyApiResponseSignature(publicID string, version string, signature string) bool {
403+
urlParams := make(url.Values)
404+
urlParams.Set("public_id", publicID)
405+
urlParams.Set("version", version)
406+
407+
// Use signature version 1 for API response validation (legacy behavior)
408+
expectedSignature, err := api.SignParametersUsingAlgoAndVersion(urlParams, u.Config.Cloud.APISecret,
409+
u.Config.Cloud.GetSignatureAlgorithm(), 1)
410+
if err != nil {
411+
return false
412+
}
413+
414+
return signature == expectedSignature
415+
}
416+
417+
// VerifyNotificationSignature validates notification signature against Cloudinary configuration.
418+
// It validates webhook notifications by checking the signature against the expected payload hash.
419+
func (u *API) VerifyNotificationSignature(body string, timestamp int64, receivedSignature string, validFor int64) bool {
420+
if validFor <= 0 {
421+
validFor = 7200 // Default: 2 hours
422+
}
423+
424+
currentTimestamp := time.Now().Unix()
425+
isSignatureExpired := timestamp <= currentTimestamp-validFor
426+
if isSignatureExpired {
427+
return false
428+
}
429+
430+
payload := fmt.Sprintf("%s%d", body, timestamp)
431+
rawSignature, err := signature.Sign(payload, u.Config.Cloud.APISecret, u.Config.Cloud.GetSignatureAlgorithm())
432+
if err != nil {
433+
return false
434+
}
435+
expectedSignature := hex.EncodeToString(rawSignature)
436+
437+
return receivedSignature == expectedSignature
438+
}

api/uploader/upload_asset_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
)
2121

2222
var ctx = context.Background()
23-
var uploadAPI, _ = uploader.New()
2423

2524
const largeImagePublicID = "go_test_large_image"
2625
const largeImageSize = 5880138

api/uploader/upload_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package uploader_test
2+
3+
import (
4+
"encoding/hex"
5+
"fmt"
6+
"net/url"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
12+
"github.com/cloudinary/cloudinary-go/v2/api"
13+
"github.com/cloudinary/cloudinary-go/v2/api/uploader"
14+
"github.com/cloudinary/cloudinary-go/v2/internal/signature"
15+
)
16+
17+
// TestUploader_VerifyApiResponseSignature tests API response signature verification.
18+
func TestUploader_VerifyApiResponseSignature(t *testing.T) {
19+
const publicID1 = "b8sjhoslj8cq8ovoa0ma"
20+
const publicID2 = "z5sjhoskl2cq8ovoa0mv"
21+
const version1 = "1555337587"
22+
const version2 = "1555337588"
23+
24+
// Test valid signature
25+
urlParams := make(url.Values)
26+
urlParams.Set("public_id", publicID1)
27+
urlParams.Set("version", version1)
28+
correctSignature, err := api.SignParametersUsingAlgoAndVersion(urlParams, uploadAPI.Config.Cloud.APISecret,
29+
uploadAPI.Config.Cloud.GetSignatureAlgorithm(), 1)
30+
if err != nil {
31+
t.Error(err)
32+
}
33+
34+
isValid := uploadAPI.VerifyApiResponseSignature(publicID1, version1, correctSignature)
35+
assert.True(t, isValid, "The response signature is valid for the same parameters")
36+
37+
// Test invalid signature with wrong version
38+
urlParams.Set("version", version2)
39+
newVersionSignature, err := api.SignParametersUsingAlgoAndVersion(urlParams, uploadAPI.Config.Cloud.APISecret,
40+
uploadAPI.Config.Cloud.GetSignatureAlgorithm(), 1)
41+
if err != nil {
42+
t.Error(err)
43+
}
44+
45+
isValid = uploadAPI.VerifyApiResponseSignature(publicID1, version1, newVersionSignature)
46+
assert.False(t, isValid, "The response signature is invalid for the wrong version")
47+
48+
// Test invalid signature with wrong resource
49+
urlParams.Set("version", version1)
50+
urlParams.Set("public_id", publicID2)
51+
anotherResourceSignature, err := api.SignParametersUsingAlgoAndVersion(urlParams, uploadAPI.Config.Cloud.APISecret,
52+
uploadAPI.Config.Cloud.GetSignatureAlgorithm(), 1)
53+
if err != nil {
54+
t.Error(err)
55+
}
56+
57+
isValid = uploadAPI.VerifyApiResponseSignature(publicID1, version1, anotherResourceSignature)
58+
assert.False(t, isValid, "The response signature is invalid for the wrong resource")
59+
}
60+
61+
// TestUploader_VerifyApiResponseSignatureWithAmpersand tests signature verification with & characters.
62+
func TestUploader_VerifyApiResponseSignatureWithAmpersand(t *testing.T) {
63+
const testSecret = "hdcixPpR2iKERPwqvH6sHdK9cyac"
64+
65+
tempConfig := uploadAPI.Config
66+
tempConfig.Cloud.APISecret = testSecret
67+
tempAPI := &uploader.API{Config: tempConfig}
68+
publicIDWithAmpersand := "callback?a=1&tags=hello,world"
69+
version := "1568810420"
70+
71+
urlParams := make(url.Values)
72+
urlParams.Set("public_id", publicIDWithAmpersand)
73+
urlParams.Set("version", version)
74+
v1Signature, err := api.SignParametersUsingAlgoAndVersion(urlParams, testSecret, signature.SHA1, 1)
75+
if err != nil {
76+
t.Error(err)
77+
}
78+
isValid := tempAPI.VerifyApiResponseSignature(publicIDWithAmpersand, version, v1Signature)
79+
assert.True(t, isValid, "Should verify signature correctly with version 1")
80+
}
81+
82+
// TestUploader_VerifyNotificationSignature tests webhook notification signature verification.
83+
func TestUploader_VerifyNotificationSignature(t *testing.T) {
84+
const testSecret = "hdcixPpR2iKERPwqvH6sHdK9cyac"
85+
body := `{"public_id":"b8sjhoslj8cq8ovoa0ma","version":"1555337587","width":"1000","height":"800"}`
86+
87+
tempConfig := uploadAPI.Config
88+
tempConfig.Cloud.APISecret = testSecret
89+
tempConfig.Cloud.SignatureAlgorithm = signature.SHA1
90+
tempAPI := &uploader.API{Config: tempConfig}
91+
92+
currentTimestamp := time.Now().Unix()
93+
validResponseTimestamp := currentTimestamp - 5000
94+
95+
payload := fmt.Sprintf("%s%d", body, validResponseTimestamp)
96+
rawSignature, _ := signature.Sign(payload, testSecret, signature.SHA1)
97+
validSignature := hex.EncodeToString(rawSignature)
98+
99+
// Test valid signature with sufficient time
100+
isValid := tempAPI.VerifyNotificationSignature(body, validResponseTimestamp, validSignature, 7200)
101+
assert.True(t, isValid, "The notification signature is valid for matching and not expired signature")
102+
103+
// Test expired signature
104+
isValid = tempAPI.VerifyNotificationSignature(body, validResponseTimestamp, validSignature, 4000)
105+
assert.False(t, isValid, "The notification signature is invalid for matching but expired signature")
106+
107+
// Test invalid signature
108+
isValid = tempAPI.VerifyNotificationSignature(body, validResponseTimestamp, validSignature+"chars", 7200)
109+
assert.False(t, isValid, "The notification signature is invalid for non matching and not expired signature")
110+
111+
// Test invalid signature with expiration
112+
isValid = tempAPI.VerifyNotificationSignature(body, validResponseTimestamp, validSignature+"chars", 4000)
113+
assert.False(t, isValid, "The notification signature is invalid for non matching and expired signature")
114+
}
115+
116+
// TestUploader_VerifyNotificationSignatureWithSHA256 tests notification signature verification with SHA256.
117+
func TestUploader_VerifyNotificationSignatureWithSHA256(t *testing.T) {
118+
const testSecret = "hdcixPpR2iKERPwqvH6sHdK9cyac"
119+
body := `{}`
120+
121+
tempConfig := uploadAPI.Config
122+
tempConfig.Cloud.APISecret = testSecret
123+
tempConfig.Cloud.SignatureAlgorithm = signature.SHA256
124+
tempAPI := &uploader.API{Config: tempConfig}
125+
126+
currentTimestamp := time.Now().Unix()
127+
validResponseTimestamp := currentTimestamp - 5000
128+
129+
payload := fmt.Sprintf("%s%d", body, validResponseTimestamp)
130+
rawSignature, _ := signature.Sign(payload, testSecret, signature.SHA256)
131+
correctSignature := hex.EncodeToString(rawSignature)
132+
133+
// Test valid signature with SHA256 algorithm
134+
isValid := tempAPI.VerifyNotificationSignature(body, validResponseTimestamp, correctSignature, 7200)
135+
assert.True(t, isValid, "The notification signature is valid with SHA256")
136+
}

config/cloud.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Cloud struct {
1111
APISecret string `schema:"-"`
1212
OAuthToken string `schema:"oauth_token"`
1313
SignatureAlgorithm string `schema:"signature_algorithm"`
14+
SignatureVersion int `schema:"signature_version" default:"2"`
1415
}
1516

1617
// GetSignatureAlgorithm returns the signature algorithm.
@@ -21,3 +22,12 @@ func (c Cloud) GetSignatureAlgorithm() string {
2122

2223
return c.SignatureAlgorithm
2324
}
25+
26+
// GetSignatureVersion returns the signature version.
27+
func (c Cloud) GetSignatureVersion() int {
28+
if c.SignatureVersion == 0 {
29+
return 2
30+
}
31+
32+
return c.SignatureVersion
33+
}

config/configuration_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func TestConfiguration_CreateInstance(t *testing.T) {
4747

4848
// check a few default values
4949
assert.EqualValues(t, signature.SHA1, c.Cloud.GetSignatureAlgorithm())
50+
assert.EqualValues(t, 2, c.Cloud.GetSignatureVersion())
5051
assert.EqualValues(t, 60, c.API.Timeout)
5152
assert.EqualValues(t, true, c.URL.Secure)
5253
}
@@ -107,3 +108,21 @@ func TestConfiguration_AuthToken(t *testing.T) {
107108
assert.EqualValues(t, 3, c.AuthToken.Expiration)
108109
assert.EqualValues(t, 2, c.AuthToken.Duration)
109110
}
111+
112+
func TestConfiguration_SignatureVersion(t *testing.T) {
113+
c, err := config.NewFromURL(cldtest.CldURL + "?signature_version=1")
114+
if err != nil {
115+
t.Error("Error: ", err)
116+
}
117+
118+
assert.Equal(t, cldtest.CloudName, c.Cloud.CloudName)
119+
assert.EqualValues(t, 1, c.Cloud.GetSignatureVersion())
120+
121+
// Test default signature version
122+
c2, err := config.NewFromURL(cldtest.CldURL)
123+
if err != nil {
124+
t.Error("Error: ", err)
125+
}
126+
127+
assert.EqualValues(t, 2, c2.Cloud.GetSignatureVersion())
128+
}

0 commit comments

Comments
 (0)