Skip to content

Commit d70ddab

Browse files
Fix API parameters signature
1 parent 53c25d2 commit d70ddab

File tree

3 files changed

+207
-18
lines changed

3 files changed

+207
-18
lines changed

cloudinary/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ def __init__(self):
183183

184184
if not self.signature_algorithm:
185185
self.signature_algorithm = utils.SIGNATURE_SHA1
186+
if not self.signature_version:
187+
self.signature_version = 2
186188

187189
def _config_from_parsed_url(self, parsed_url):
188190
if not self._is_url_scheme_valid(parsed_url):
@@ -280,7 +282,7 @@ def __len__(self):
280282
return len(self.public_id) if self.public_id is not None else 0
281283

282284
def validate(self):
283-
return self.signature == self.get_expected_signature()
285+
return utils.verify_api_response_signature(self.public_id, self.version, self.signature)
284286

285287
def get_prep_value(self):
286288
if None in [self.public_id,
@@ -301,7 +303,7 @@ def get_presigned(self):
301303

302304
def get_expected_signature(self):
303305
return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret,
304-
config().signature_algorithm)
306+
config().signature_algorithm, signature_version=1)
305307

306308
@property
307309
def url(self):

cloudinary/utils.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -619,24 +619,72 @@ def sign_request(params, options):
619619
if not api_secret:
620620
raise ValueError("Must supply api_secret")
621621
signature_algorithm = options.get("signature_algorithm", cloudinary.config().signature_algorithm)
622+
signature_version = options.get("signature_version", cloudinary.config().signature_version)
622623

623624
params = cleanup_params(params)
624-
params["signature"] = api_sign_request(params, api_secret, signature_algorithm)
625+
params["signature"] = api_sign_request(params, api_secret, signature_algorithm, signature_version)
625626
params["api_key"] = api_key
626627

627628
return params
628629

629630

630-
def api_sign_request(params_to_sign, api_secret, algorithm=SIGNATURE_SHA1):
631-
params = [(k + "=" + (
632-
",".join(v) if isinstance(v, list) else
633-
str(v).lower() if isinstance(v, bool) else
634-
str(v)
635-
)) for k, v in params_to_sign.items() if v]
636-
to_sign = "&".join(sorted(params))
631+
def api_sign_request(params_to_sign, api_secret, algorithm=SIGNATURE_SHA1, signature_version=2):
632+
"""
633+
Signs API request parameters using the specified algorithm and signature version.
634+
635+
:param params_to_sign: Parameters to include in the signature
636+
:param api_secret: API secret key
637+
:param algorithm: Signature algorithm (default: SHA1)
638+
:param signature_version: Signature version (default: 2)
639+
- Version 1: Original behavior without parameter encoding
640+
- Version 2+: Includes parameter encoding to prevent parameter smuggling
641+
:return: Computed signature
642+
"""
643+
to_sign = api_string_to_sign(params_to_sign, signature_version)
637644
return compute_hex_hash(to_sign + api_secret, algorithm)
638645

639646

647+
def api_string_to_sign(params_to_sign, signature_version=2):
648+
"""
649+
Generates a string to be signed for API requests.
650+
651+
:param params_to_sign: Parameters to include in the signature
652+
:param signature_version: Version of signature algorithm to use:
653+
- Version 1: Original behavior without parameter encoding
654+
- Version 2+ (default): Includes parameter encoding to prevent parameter smuggling
655+
:return: String to be signed
656+
"""
657+
params = []
658+
for k, v in params_to_sign.items():
659+
if v:
660+
if isinstance(v, list):
661+
value = ",".join(v)
662+
elif isinstance(v, bool):
663+
value = str(v).lower()
664+
else:
665+
value = str(v)
666+
667+
param_string = k + "=" + value
668+
if signature_version >= 2:
669+
param_string = _encode_param(param_string)
670+
params.append(param_string)
671+
672+
return "&".join(sorted(params))
673+
674+
675+
def _encode_param(value):
676+
"""
677+
Encodes a parameter for safe inclusion in URL query strings.
678+
679+
Specifically replaces "&" characters with their percent-encoded equivalent "%26"
680+
to prevent them from being interpreted as parameter separators in URL query strings.
681+
682+
:param value: The parameter to encode
683+
:return: Encoded parameter
684+
"""
685+
return str(value).replace("&", "%26")
686+
687+
640688
def breakpoint_settings_mapper(breakpoint_settings):
641689
breakpoint_settings = copy.deepcopy(breakpoint_settings)
642690
transformation = breakpoint_settings.get("transformation")
@@ -1566,7 +1614,7 @@ def verify_api_response_signature(public_id, version, signature, algorithm=None)
15661614
:param version: The version of the asset as returned in the API response
15671615
:param signature: Actual signature. Can be retrieved from the X-Cld-Signature header
15681616
:param algorithm: Name of hashing algorithm to use for calculation of HMACs.
1569-
By default uses `cloudinary.config().signature_algorithm`
1617+
By default, uses `cloudinary.config().signature_algorithm`
15701618
15711619
:return: Boolean result of the validation
15721620
"""
@@ -1576,10 +1624,12 @@ def verify_api_response_signature(public_id, version, signature, algorithm=None)
15761624
parameters_to_sign = {'public_id': public_id,
15771625
'version': version}
15781626

1627+
# Use signature version 1 for backward compatibility
15791628
return signature == api_sign_request(
15801629
parameters_to_sign,
15811630
cloudinary.config().api_secret,
1582-
algorithm or cloudinary.config().signature_algorithm
1631+
algorithm or cloudinary.config().signature_algorithm,
1632+
signature_version=1
15831633
)
15841634

15851635

@@ -1592,7 +1642,7 @@ def verify_notification_signature(body, timestamp, signature, valid_for=7200, al
15921642
:param signature: Actual signature. Can be retrieved from the X-Cld-Signature header
15931643
:param valid_for: The desired time in seconds for considering the request valid
15941644
:param algorithm: Name of hashing algorithm to use for calculation of HMACs.
1595-
By default uses `cloudinary.config().signature_algorithm`
1645+
By default, uses `cloudinary.config().signature_algorithm`
15961646
15971647
:return: Boolean result of the validation
15981648
"""

test/test_utils.py

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555

5656
MOCKED_NOW = 1549533574
5757
API_SECRET = 'X7qLTrsES31MzxxkxPPA-pAGGfU'
58+
API_SIGN_REQUEST_TEST_SECRET = "hdcixPpR2iKERPwqvH6sHdK9cyac"
59+
API_SIGN_REQUEST_CLOUD_NAME = "dn6ot3ged"
5860

5961

6062
class TestUtils(unittest.TestCase):
@@ -76,7 +78,8 @@ def setUp(self):
7678
cname=None, # for these tests without actual upload, we ignore cname
7779
api_key="a", api_secret="b",
7880
secure_distribution=None,
79-
private_cdn=False)
81+
private_cdn=False,
82+
signature_version=2)
8083

8184
def __test_cloudinary_url(self, public_id=TEST_ID, options=None, expected_url=None, expected_options=None):
8285
if expected_options is None:
@@ -1443,17 +1446,151 @@ def test_support_long_url_signature(self):
14431446
expected_url=DEFAULT_UPLOAD_PATH + long_signature + "/" + image_name)
14441447

14451448
def test_api_sign_request_sha1(self):
1446-
params = dict(cloud_name="dn6ot3ged", timestamp=1568810420, username="[email protected]")
1447-
signature = api_sign_request(params, "hdcixPpR2iKERPwqvH6sHdK9cyac")
1449+
params = dict(cloud_name=API_SIGN_REQUEST_CLOUD_NAME, timestamp=1568810420, username="[email protected]")
1450+
signature = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET)
14481451
expected = "14c00ba6d0dfdedbc86b316847d95b9e6cd46d94"
14491452
self.assertEqual(expected, signature)
14501453

14511454
def test_api_sign_request_sha256(self):
1452-
params = dict(cloud_name="dn6ot3ged", timestamp=1568810420, username="[email protected]")
1453-
signature = api_sign_request(params, "hdcixPpR2iKERPwqvH6sHdK9cyac", cloudinary.utils.SIGNATURE_SHA256)
1455+
params = dict(cloud_name=API_SIGN_REQUEST_CLOUD_NAME, timestamp=1568810420, username="[email protected]")
1456+
signature = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA256)
14541457
expected = "45ddaa4fa01f0c2826f32f669d2e4514faf275fe6df053f1a150e7beae58a3bd"
14551458
self.assertEqual(expected, signature)
14561459

1460+
def test_api_sign_request_prevents_parameter_smuggling(self):
1461+
"""Should prevent parameter smuggling via & characters in parameter values"""
1462+
# Test with notification_url containing & characters
1463+
params_with_ampersand = {
1464+
"cloud_name": API_SIGN_REQUEST_CLOUD_NAME,
1465+
"timestamp": 1568810420,
1466+
"notification_url": "https://fake.com/callback?a=1&tags=hello,world"
1467+
}
1468+
1469+
signature_with_ampersand = api_sign_request(params_with_ampersand, API_SIGN_REQUEST_TEST_SECRET)
1470+
1471+
# Test that attempting to smuggle parameters by splitting the notification_url fails
1472+
params_smuggled = {
1473+
"cloud_name": API_SIGN_REQUEST_CLOUD_NAME,
1474+
"timestamp": 1568810420,
1475+
"notification_url": "https://fake.com/callback?a=1",
1476+
"tags": "hello,world" # This would be smuggled if & encoding didn't work
1477+
}
1478+
1479+
signature_smuggled = api_sign_request(params_smuggled, API_SIGN_REQUEST_TEST_SECRET)
1480+
1481+
# The signatures should be different, proving that parameter smuggling is prevented
1482+
self.assertNotEqual(signature_with_ampersand, signature_smuggled,
1483+
"Signatures should be different to prevent parameter smuggling")
1484+
1485+
# Verify the expected signature for the properly encoded case
1486+
expected_signature = "4fdf465dd89451cc1ed8ec5b3e314e8a51695704"
1487+
self.assertEqual(expected_signature, signature_with_ampersand)
1488+
1489+
# Verify the expected signature for the smuggled parameters case
1490+
expected_smuggled_signature = "7b4e3a539ff1fa6e6700c41b3a2ee77586a025f9"
1491+
self.assertEqual(expected_smuggled_signature, signature_smuggled)
1492+
1493+
def test_api_sign_request_signature_versions(self):
1494+
"""Should use signature version 1 (without parameter encoding) for backward compatibility"""
1495+
public_id_with_ampersand = 'tests/logo&version=2'
1496+
test_version = 1
1497+
1498+
expected_signature_v1 = api_sign_request(
1499+
{'public_id': public_id_with_ampersand, 'version': test_version},
1500+
API_SIGN_REQUEST_TEST_SECRET,
1501+
cloudinary.utils.SIGNATURE_SHA1,
1502+
signature_version=1
1503+
)
1504+
1505+
expected_signature_v2 = api_sign_request(
1506+
{'public_id': public_id_with_ampersand, 'version': test_version},
1507+
API_SIGN_REQUEST_TEST_SECRET,
1508+
cloudinary.utils.SIGNATURE_SHA1,
1509+
signature_version=2
1510+
)
1511+
1512+
self.assertNotEqual(expected_signature_v1, expected_signature_v2)
1513+
1514+
# verify_api_response_signature should use version 1 for backward compatibility
1515+
with patch('cloudinary.config', return_value=cloudinary.config(api_secret=API_SIGN_REQUEST_TEST_SECRET)):
1516+
self.assertTrue(
1517+
verify_api_response_signature(
1518+
public_id_with_ampersand,
1519+
test_version,
1520+
expected_signature_v1
1521+
)
1522+
)
1523+
1524+
self.assertFalse(
1525+
verify_api_response_signature(
1526+
public_id_with_ampersand,
1527+
test_version,
1528+
expected_signature_v2
1529+
)
1530+
)
1531+
1532+
def test_signature_version_config_support(self):
1533+
"""Should use signature_version from config and produce different signatures for v1 vs v2"""
1534+
# Use params with & characters to show the encoding difference between versions
1535+
params = {'public_id': 'test&image', 'notification_url': 'https://example.com/callback?param=value&other=data'}
1536+
1537+
# Test with config signature_version = 1
1538+
cloudinary.config().signature_version = 1
1539+
1540+
# Test sign_request function uses config values
1541+
options_with_config = {'api_key': 'test_key', 'api_secret': API_SIGN_REQUEST_TEST_SECRET}
1542+
signed_params_config_v1 = cloudinary.utils.sign_request(params.copy(), options_with_config)
1543+
1544+
# Test explicit signature version
1545+
options_explicit_v1 = options_with_config.copy()
1546+
options_explicit_v1['signature_version'] = 1
1547+
signed_params_explicit_v1 = cloudinary.utils.sign_request(params.copy(), options_explicit_v1)
1548+
1549+
self.assertEqual(signed_params_config_v1['signature'], signed_params_explicit_v1['signature'])
1550+
1551+
# Test with config signature_version = 2
1552+
cloudinary.config().signature_version = 2
1553+
1554+
signed_params_config_v2 = cloudinary.utils.sign_request(params.copy(), options_with_config)
1555+
1556+
options_explicit_v2 = options_with_config.copy()
1557+
options_explicit_v2['signature_version'] = 2
1558+
signed_params_explicit_v2 = cloudinary.utils.sign_request(params.copy(), options_explicit_v2)
1559+
1560+
self.assertEqual(signed_params_config_v2['signature'], signed_params_explicit_v2['signature'])
1561+
1562+
# Verify that v1 and v2 actually produce different signatures due to parameter encoding
1563+
self.assertNotEqual(signed_params_config_v1['signature'], signed_params_config_v2['signature'],
1564+
"Signature v1 and v2 should be different for parameters with & characters")
1565+
1566+
def test_sign_request_with_signature_version(self):
1567+
"""Should support signature_version parameter in sign_request function"""
1568+
params = {'public_id': 'test_image', 'version': 1234}
1569+
options = {'api_key': 'test_key', 'api_secret': API_SIGN_REQUEST_TEST_SECRET}
1570+
1571+
# Test with signature_version in options
1572+
options_v1 = options.copy()
1573+
options_v1['signature_version'] = 1
1574+
signed_params_v1 = cloudinary.utils.sign_request(params.copy(), options_v1)
1575+
1576+
options_v2 = options.copy()
1577+
options_v2['signature_version'] = 2
1578+
signed_params_v2 = cloudinary.utils.sign_request(params.copy(), options_v2)
1579+
1580+
# The signatures should be different for different versions (for params with & characters)
1581+
# For these simple params without & they might be the same, but let's test the structure
1582+
self.assertIn('signature', signed_params_v1)
1583+
self.assertIn('signature', signed_params_v2)
1584+
self.assertIn('api_key', signed_params_v1)
1585+
self.assertIn('api_key', signed_params_v2)
1586+
1587+
# Test that signature_version is passed through correctly
1588+
expected_sig_v1 = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA1, 1)
1589+
expected_sig_v2 = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA1, 2)
1590+
1591+
self.assertEqual(signed_params_v1['signature'], expected_sig_v1)
1592+
self.assertEqual(signed_params_v2['signature'], expected_sig_v2)
1593+
14571594

14581595
if __name__ == '__main__':
14591596
unittest.main()

0 commit comments

Comments
 (0)