From 692e9ea52fa0d04a8ae70e387886a80098f24a7f Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Tue, 13 May 2025 15:17:34 +0300 Subject: [PATCH 1/3] Add support for `429 Too Many Requests` HTTP status code --- src/Api/BaseApiClient.php | 3 ++- src/Api/Utils/HttpStatusCode.php | 10 ++++++++++ tests/Integration/Search/SearchApiTest.php | 19 ------------------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/Api/BaseApiClient.php b/src/Api/BaseApiClient.php index f6cc08ac..ceb009a9 100644 --- a/src/Api/BaseApiClient.php +++ b/src/Api/BaseApiClient.php @@ -53,7 +53,8 @@ class BaseApiClient HttpStatusCode::FORBIDDEN => NotAllowed::class, HttpStatusCode::NOT_FOUND => NotFound::class, HttpStatusCode::CONFLICT => AlreadyExists::class, - HttpStatusCode::ENHANCE_YOUR_CALM => RateLimited::class, // RFC6585::TOO_MANY_REQUESTS + HttpStatusCode::ENHANCE_YOUR_CALM => RateLimited::class, + HttpStatusCode::TOO_MANY_REQUESTS => RateLimited::class, HttpStatusCode::INTERNAL_SERVER_ERROR => GeneralError::class, ]; diff --git a/src/Api/Utils/HttpStatusCode.php b/src/Api/Utils/HttpStatusCode.php index 7e367745..90970a1d 100644 --- a/src/Api/Utils/HttpStatusCode.php +++ b/src/Api/Utils/HttpStatusCode.php @@ -90,6 +90,16 @@ class HttpStatusCode */ public const ENHANCE_YOUR_CALM = 420; + /** + * The 429 (Too Many Requests) status code indicates the user has sent too + * many requests in a given amount of time ("rate limiting"). + * + * @link https://datatracker.ietf.org/doc/html/rfc6585#section-4 + * + * @var int + */ + public const TOO_MANY_REQUESTS = 429; + /** * The 500 (Internal Server Error) status code indicates that the server * encountered an unexpected condition that prevented it from fulfilling the diff --git a/tests/Integration/Search/SearchApiTest.php b/tests/Integration/Search/SearchApiTest.php index 3532b948..fbd26d73 100644 --- a/tests/Integration/Search/SearchApiTest.php +++ b/tests/Integration/Search/SearchApiTest.php @@ -281,25 +281,6 @@ public function testFindAssetsByGeneralExpression() self::assertValidAsset($result['resources'][0]); } - /** - * Find assets without limiting expression to certain fields but with an underscore in the expression - * Shows results containing the entire expression in any string field - * Shows results containing the entire expression or a part of it (parts are separated by underscore) in public_id - * - * @throws ApiError - */ - public function testFindAssetsByGeneralExpressionWithUnderscore() - { - $result = $this->search - ->expression(self::$MULTI_STRING) - ->maxResults(2) - ->execute(); - - self::assertEquals(2, $result['total_count']); - self::assertCount(2, $result['resources']); - self::assertValidAsset($result['resources'][0]); - } - /** * Find assets with an expression limiting the search expression to certain fields * Shows results containing given text in tags field From a71e0a0b8e7f6f5b6443b03e737c8d0108270fa6 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Tue, 17 Jun 2025 20:14:03 +0300 Subject: [PATCH 2/3] Fix API parameters signature --- src/Api/Utils/ApiUtils.php | 19 ++++-- src/Configuration/CloudConfig.php | 8 +++ src/Configuration/CloudConfigTrait.php | 14 ++++ tests/Unit/Utils/ApiUtilsTest.php | 94 +++++++++++++++++++++++--- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/Api/Utils/ApiUtils.php b/src/Api/Utils/ApiUtils.php index b8ebeb3f..58d22850 100644 --- a/src/Api/Utils/ApiUtils.php +++ b/src/Api/Utils/ApiUtils.php @@ -242,8 +242,16 @@ public static function serializeResponsiveBreakpoints(?array $breakpoints): bool * * @internal */ - public static function serializeQueryParams(array $parameters = []): string + public static function serializeQueryParams(array $parameters = [], int $signatureVersion = 2): string { + // Version 2: URL encode & characters in values to prevent parameter smuggling + if ($signatureVersion >= 2) { + $parameters = ArrayUtils::mapAssoc( + static fn($key, $value) => str_replace('&', '%26', $value), + $parameters + ); + } + return ArrayUtils::implodeAssoc( $parameters, self::QUERY_STRING_OUTER_DELIMITER, @@ -257,6 +265,7 @@ public static function serializeQueryParams(array $parameters = []): string * @param array $parameters Parameters to sign. * @param string $secret The API secret of the cloud. * @param string $signatureAlgorithm Signature algorithm + * @param int $signatureVersion Signature version (1 or 2) * * @return string The signature. * @@ -265,13 +274,14 @@ public static function serializeQueryParams(array $parameters = []): string public static function signParameters( array $parameters, string $secret, - string $signatureAlgorithm = Utils::ALGO_SHA1 + string $signatureAlgorithm = Utils::ALGO_SHA1, + int $signatureVersion = 2 ): string { $parameters = array_map(self::class . '::serializeSimpleApiParam', $parameters); ksort($parameters); - $signatureContent = self::serializeQueryParams($parameters); + $signatureContent = self::serializeQueryParams($parameters, $signatureVersion); return Utils::sign($signatureContent, $secret, false, $signatureAlgorithm); } @@ -287,7 +297,8 @@ public static function signRequest(?array &$parameters, CloudConfig $cloudConfig $parameters['signature'] = self::signParameters( $parameters, $cloudConfig->apiSecret, - $cloudConfig->signatureAlgorithm + $cloudConfig->signatureAlgorithm, + $cloudConfig->signatureVersion ); $parameters['api_key'] = $cloudConfig->apiKey; } diff --git a/src/Configuration/CloudConfig.php b/src/Configuration/CloudConfig.php index 19b80698..5ecc1dda 100644 --- a/src/Configuration/CloudConfig.php +++ b/src/Configuration/CloudConfig.php @@ -19,6 +19,7 @@ * target="_blank">Get account details from the Cloudinary Console. * * @property ?string $signatureAlgorithm By default, set to self::DEFAULT_SIGNATURE_ALGORITHM. + * @property ?int $signatureVersion By default, set to self::DEFAULT_SIGNATURE_VERSION. * * @api */ @@ -29,6 +30,7 @@ class CloudConfig extends BaseConfigSection public const CONFIG_NAME = 'cloud'; public const DEFAULT_SIGNATURE_ALGORITHM = Utils::ALGO_SHA1; + public const DEFAULT_SIGNATURE_VERSION = 2; // Supported parameters public const CLOUD_NAME = 'cloud_name'; @@ -36,6 +38,7 @@ class CloudConfig extends BaseConfigSection public const API_SECRET = 'api_secret'; public const OAUTH_TOKEN = 'oauth_token'; public const SIGNATURE_ALGORITHM = 'signature_algorithm'; + public const SIGNATURE_VERSION = 'signature_version'; /** * @var array of configuration keys that contain sensitive data that should not be exported (for example api key) @@ -69,6 +72,11 @@ class CloudConfig extends BaseConfigSection */ protected ?string $signatureAlgorithm = null; + /** + * Sets the signature version (2 by default). + */ + protected ?int $signatureVersion = null; + /** * Serialises configuration section to a string representation. * diff --git a/src/Configuration/CloudConfigTrait.php b/src/Configuration/CloudConfigTrait.php index b24d0e8f..6f57ecb0 100644 --- a/src/Configuration/CloudConfigTrait.php +++ b/src/Configuration/CloudConfigTrait.php @@ -59,6 +59,20 @@ public function signatureAlgorithm(string $signatureAlgorithm): static return $this->setCloudConfig(CloudConfig::SIGNATURE_ALGORITHM, $signatureAlgorithm); } + /** + * Sets the signature version. + * + * @param int $signatureVersion The signature version to use. (Can be 1 or 2). + * + * @return $this + * + * @api + */ + public function signatureVersion(int $signatureVersion): static + { + return $this->setCloudConfig(CloudConfig::SIGNATURE_VERSION, $signatureVersion); + } + /** * Sets the Cloud configuration key with the specified value. * diff --git a/tests/Unit/Utils/ApiUtilsTest.php b/tests/Unit/Utils/ApiUtilsTest.php index d06c9b65..8cc6c9bc 100644 --- a/tests/Unit/Utils/ApiUtilsTest.php +++ b/tests/Unit/Utils/ApiUtilsTest.php @@ -22,7 +22,10 @@ */ final class ApiUtilsTest extends UnitTestCase { - public function tearDown() + public const API_SIGN_REQUEST_TEST_SECRET = 'hdcixPpR2iKERPwqvH6sHdK9cyac'; + public const API_SIGN_REQUEST_CLOUD_NAME = 'dn6ot3ged'; + + public function tearDown(): void { parent::tearDown(); @@ -230,7 +233,7 @@ public function dataProviderSignParameters() ], [ 'value' => ['p1' => 'v1=v2*|}{ & !@#$%^&*()_;/.,?><\\/|_+a'], - 'result' => 'bbdc631f4b490c0ba65722d8dbf9300d1fd98e86', + 'result' => 'ced1e363d8db0a8d7ebcfb9e67fadbf5ee78a0f1', ], ]; } @@ -276,12 +279,12 @@ public function dataProviderSignWithAlgorithmParameters() ], [ 'value' => ['p1' => 'v1=v2*|}{ & !@#$%^&*()_;/.,?><\\/|_+a'], - 'result' => 'bbdc631f4b490c0ba65722d8dbf9300d1fd98e86', + 'result' => 'ced1e363d8db0a8d7ebcfb9e67fadbf5ee78a0f1', 'signatureAlgorithm' => 'sha1', ], [ 'value' => ['p1' => 'v1=v2*|}{ & !@#$%^&*()_;/.,?><\\/|_+a'], - 'result' => '9cdbdd04f587b41db72d66437f6dac2a379cd899c0cf3c3430925b1beca6052d', + 'result' => '0c06416c30bfc727eb2cbc9f93245be70bd6567c788b5bd93a3772e8253312bf', 'signatureAlgorithm' => 'sha256', ], ]; @@ -310,13 +313,13 @@ public function testSignParametersWithExplicitSignatureAlgorithm($value, $result public function testApiSignRequestWithGlobalConfig() { $initialParams = [ - 'cloud_name' => 'dn6ot3ged', + 'cloud_name' => self::API_SIGN_REQUEST_CLOUD_NAME, 'timestamp' => 1568810420, 'username' => 'user@cloudinary.com' ]; $params = $initialParams; - Configuration::instance()->cloud->apiSecret = 'hdcixPpR2iKERPwqvH6sHdK9cyac'; + Configuration::instance()->cloud->apiSecret = self::API_SIGN_REQUEST_TEST_SECRET; Configuration::instance()->cloud->signatureAlgorithm = Utils::ALGO_SHA256; ApiUtils::signRequest($params, Configuration::instance()->cloud); $expected = '45ddaa4fa01f0c2826f32f669d2e4514faf275fe6df053f1a150e7beae58a3bd'; @@ -335,15 +338,90 @@ public function testApiSignRequestWithGlobalConfig() public function testApiSignRequestWithExplicitConfig() { $params = [ - 'cloud_name' => 'dn6ot3ged', + 'cloud_name' => self::API_SIGN_REQUEST_CLOUD_NAME, 'timestamp' => 1568810420, 'username' => 'user@cloudinary.com' ]; - $config = new Configuration('cloudinary://key:hdcixPpR2iKERPwqvH6sHdK9cyac@test123'); + $config = new Configuration('cloudinary://key:' . self::API_SIGN_REQUEST_TEST_SECRET . '@test123'); $config->cloud->signatureAlgorithm = Utils::ALGO_SHA256; ApiUtils::signRequest($params, $config->cloud); $expected = '45ddaa4fa01f0c2826f32f669d2e4514faf275fe6df053f1a150e7beae58a3bd'; self::assertEquals($expected, $params['signature']); } + + /** + * Should prevent parameter smuggling via & characters in parameter values. + */ + public function testApiSignRequestPreventsParameterSmuggling() + { + // Test with notification_url containing & characters + $paramsWithAmpersand = [ + 'cloud_name' => self::API_SIGN_REQUEST_CLOUD_NAME, + 'timestamp' => 1568810420, + 'notification_url' => 'https://fake.com/callback?a=1&tags=hello,world' + ]; + + $config = new Configuration('cloudinary://key:' . self::API_SIGN_REQUEST_TEST_SECRET . '@test123'); + ApiUtils::signRequest($paramsWithAmpersand, $config->cloud); + $signatureWithAmpersand = $paramsWithAmpersand['signature']; + + // Test that attempting to smuggle parameters by splitting the notification_url fails + $paramsSmugggled = [ + 'cloud_name' => self::API_SIGN_REQUEST_CLOUD_NAME, + 'timestamp' => 1568810420, + 'notification_url' => 'https://fake.com/callback?a=1', + 'tags' => 'hello,world' // This would be smuggled if & encoding didn't work + ]; + + ApiUtils::signRequest($paramsSmugggled, $config->cloud); + $signatureSmugggled = $paramsSmugggled['signature']; + + // The signatures should be different, proving that parameter smuggling is prevented + self::assertNotEquals($signatureWithAmpersand, $signatureSmugggled, + 'Signatures should be different to prevent parameter smuggling'); + + // Verify the expected signature for the properly encoded case + $expectedSignature = '4fdf465dd89451cc1ed8ec5b3e314e8a51695704'; + self::assertEquals($expectedSignature, $signatureWithAmpersand); + + // Verify the expected signature for the smuggled parameters case + $expectedSmuggledSignature = '7b4e3a539ff1fa6e6700c41b3a2ee77586a025f9'; + self::assertEquals($expectedSmuggledSignature, $signatureSmugggled); + } + + /** + * Should apply the configured signature version from CloudConfig. + */ + public function testConfiguredSignatureVersionIsApplied() + { + $params = [ + 'cloud_name' => self::API_SIGN_REQUEST_CLOUD_NAME, + 'timestamp' => 1568810420, + 'notification_url' => 'https://fake.com/callback?a=1&tags=hello,world' + ]; + + $config = new Configuration('cloudinary://key:' . self::API_SIGN_REQUEST_TEST_SECRET . '@test123'); + + // Test with signature version 1 (legacy behavior - no URL encoding) + $config->cloud->signatureVersion = 1; + $paramsV1 = $params; + ApiUtils::signRequest($paramsV1, $config->cloud); + $signatureV1 = $paramsV1['signature']; + + // Test with signature version 2 (current behavior - with URL encoding) + $config->cloud->signatureVersion = 2; + $paramsV2 = $params; + ApiUtils::signRequest($paramsV2, $config->cloud); + $signatureV2 = $paramsV2['signature']; + + // Signatures should be different, proving the version setting is applied + self::assertNotEquals($signatureV1, $signatureV2, + 'Signature versions should produce different results'); + + // Version 2 should match the expected encoded signature + $expectedV2Signature = '4fdf465dd89451cc1ed8ec5b3e314e8a51695704'; + self::assertEquals($expectedV2Signature, $signatureV2, + 'Version 2 should match expected encoded signature'); + } } From 5b7d5480ba91d1df19ebb0041b23d85a228845ba Mon Sep 17 00:00:00 2001 From: cloudinary-bot Date: Tue, 17 Jun 2025 17:17:38 +0000 Subject: [PATCH 3/3] Version 3.1.1 --- CHANGELOG.md | 6 ++++++ composer.json | 2 +- docs/sami_config.php | 2 +- src/Cloudinary.php | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cf517c..657953a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +3.1.1 / 2025-06-17 +================== + + * Add support for `429 Too Many Requests` HTTP status code + * Fix API parameters signature + 3.1.0 / 2025-01-14 ================== diff --git a/composer.json b/composer.json index 2793889f..08468247 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "cloudinary/cloudinary_php", - "version": "3.1.0", + "version": "3.1.1", "description": "Cloudinary PHP SDK", "keywords": [ "cloudinary", diff --git a/docs/sami_config.php b/docs/sami_config.php index 5d52799b..dac7e5e8 100644 --- a/docs/sami_config.php +++ b/docs/sami_config.php @@ -14,7 +14,7 @@ 'theme' => 'cloudinary', 'template_dirs' => [$docsDir . 'themes'], 'title' => 'Cloudinary PHP SDK', - 'version' => '3.1.0', + 'version' => '3.1.1', 'build_dir' => $docsDir . 'build', 'cache_dir' => $docsDir . 'cache', 'default_opened_level' => 1, diff --git a/src/Cloudinary.php b/src/Cloudinary.php index c8de42d7..6a7a48bc 100644 --- a/src/Cloudinary.php +++ b/src/Cloudinary.php @@ -34,7 +34,7 @@ class Cloudinary * * @var string VERSION */ - public const VERSION = '3.1.0'; + public const VERSION = '3.1.1'; /** * Defines the Cloudinary cloud details and other global configuration options.