Skip to content

Commit 10db5a8

Browse files
committed
Merge remote-tracking branch 'origin/master' into feature-restore-resources-by-asset-id
2 parents 096ee67 + 923a66e commit 10db5a8

File tree

10 files changed

+415
-191
lines changed

10 files changed

+415
-191
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
2.7.0 / 2025-06-18
2+
==================
3+
4+
* fix: prevent parameter injection via ampersand in parameter values (#709)
5+
6+
2.6.1 / 2025-05-05
7+
==================
8+
9+
10+
11+
2.6.1-rc.1 / 2025-05-05
12+
==================
13+
14+
* fix: uploader interface
15+
116
2.6.0 / 2025-03-11
217
==================
318

lib/preloaded_file.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,7 @@ class PreloadedFile {
2424
}
2525

2626
is_valid() {
27-
let expected_signature;
28-
expected_signature = utils.api_sign_request({
29-
public_id: this.public_id,
30-
version: this.version
31-
}, config().api_secret);
32-
return this.signature === expected_signature;
27+
return utils.verify_api_response_signature(this.public_id, this.version, this.signature);
3328
}
3429

3530
static split_format(identifier) {

lib/utils/index.js

Lines changed: 78 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -544,9 +544,7 @@ function generate_transformation_string(options) {
544544
let base_transformations = toArray(consumeOption(options, "transformation", []));
545545
let named_transformation = [];
546546
if (base_transformations.some(isObject)) {
547-
base_transformations = base_transformations.map(tr => utils.generate_transformation_string(
548-
isObject(tr) ? clone(tr) : {transformation: tr}
549-
));
547+
base_transformations = base_transformations.map(tr => utils.generate_transformation_string(isObject(tr) ? clone(tr) : {transformation: tr}));
550548
} else {
551549
named_transformation = base_transformations.join(".");
552550
base_transformations = [];
@@ -555,9 +553,7 @@ function generate_transformation_string(options) {
555553
if (isArray(effect)) {
556554
effect = effect.join(":");
557555
} else if (isObject(effect)) {
558-
effect = entries(effect).map(
559-
([key, value]) => `${key}:${value}`
560-
);
556+
effect = entries(effect).map(([key, value]) => `${key}:${value}`);
561557
}
562558
let border = consumeOption(options, "border");
563559
if (isObject(border)) {
@@ -634,9 +630,7 @@ function generate_transformation_string(options) {
634630
.map(([key, value]) => {
635631
delete options[key];
636632
return `${key}_${normalize_expression(value)}`;
637-
}).sort().concat(
638-
variablesParam.map(([name, value]) => `${name}_${normalize_expression(value)}`)
639-
).join(',');
633+
}).sort().concat(variablesParam.map(([name, value]) => `${name}_${normalize_expression(value)}`)).join(',');
640634

641635
let transformations = entries(params)
642636
.filter(([key, value]) => utils.present(value))
@@ -649,8 +643,7 @@ function generate_transformation_string(options) {
649643
base_transformations.push(transformations);
650644
transformations = base_transformations;
651645
if (responsive_width) {
652-
let responsive_width_transformation = config().responsive_width_transformation
653-
|| DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION;
646+
let responsive_width_transformation = config().responsive_width_transformation || DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION;
654647

655648
transformations.push(utils.generate_transformation_string(clone(responsive_width_transformation)));
656649
}
@@ -745,27 +738,7 @@ function updateable_resource_params(options, params = {}) {
745738
* A list of keys used by the url() function.
746739
* @private
747740
*/
748-
const URL_KEYS = [
749-
'api_secret',
750-
'auth_token',
751-
'cdn_subdomain',
752-
'cloud_name',
753-
'cname',
754-
'format',
755-
'long_url_signature',
756-
'private_cdn',
757-
'resource_type',
758-
'secure',
759-
'secure_cdn_subdomain',
760-
'secure_distribution',
761-
'shorten',
762-
'sign_url',
763-
'ssl_detected',
764-
'type',
765-
'url_suffix',
766-
'use_root_path',
767-
'version'
768-
];
741+
const URL_KEYS = ['api_secret', 'auth_token', 'cdn_subdomain', 'cloud_name', 'cname', 'format', 'long_url_signature', 'private_cdn', 'resource_type', 'secure', 'secure_cdn_subdomain', 'secure_distribution', 'shorten', 'sign_url', 'ssl_detected', 'type', 'url_suffix', 'use_root_path', 'version'];
769742

770743
/**
771744
* Create a new object with only URL parameters
@@ -930,9 +903,7 @@ function url(public_id, options = {}) {
930903
urlAnalytics
931904
};
932905

933-
let analyticsOptions = getAnalyticsOptions(
934-
Object.assign({}, options, sdkVersions)
935-
);
906+
let analyticsOptions = getAnalyticsOptions(Object.assign({}, options, sdkVersions));
936907

937908
let sdkAnalyticsSignature = getSDKAnalyticsSignature(analyticsOptions);
938909

@@ -1033,16 +1004,7 @@ function finalize_resource_type(resource_type, type, url_suffix, use_root_path,
10331004
// if cdn_domain is true uses a[1-5].cname for http.
10341005
// For https, uses the same naming scheme as 1 for shared distribution and as 2 for private distribution.
10351006

1036-
function unsigned_url_prefix(
1037-
source,
1038-
cloud_name,
1039-
private_cdn,
1040-
cdn_subdomain,
1041-
secure_cdn_subdomain,
1042-
cname,
1043-
secure,
1044-
secure_distribution
1045-
) {
1007+
function unsigned_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, secure, secure_distribution) {
10461008
let prefix;
10471009
if (cloud_name.indexOf("/") === 0) {
10481010
return '/res' + cloud_name;
@@ -1112,13 +1074,42 @@ function signed_preloaded_image(result) {
11121074
return `${result.resource_type}/upload/v${result.version}/${filter([result.public_id, result.format], utils.present).join(".")}#${result.signature}`;
11131075
}
11141076

1115-
function api_sign_request(params_to_sign, api_secret) {
1116-
let to_sign = entries(params_to_sign).filter(
1117-
([k, v]) => utils.present(v)
1118-
).map(
1119-
([k, v]) => `${k}=${toArray(v).join(",")}`
1120-
).sort().join("&");
1121-
return compute_hash(to_sign + api_secret, config().signature_algorithm || DEFAULT_SIGNATURE_ALGORITHM, 'hex');
1077+
// Encodes a parameter for safe inclusion in URL query strings (only replaces & with %26)
1078+
function encode_param(value) {
1079+
return String(value).replace(/&/g, '%26');
1080+
}
1081+
1082+
// Generates a string to be signed for API requests
1083+
function api_string_to_sign(params_to_sign, signature_version = 2) {
1084+
let params = entries(params_to_sign)
1085+
.map(([k, v]) => [String(k), Array.isArray(v) ? v.join(",") : v])
1086+
.filter(([k, v]) => v !== null && v !== undefined && v !== "");
1087+
params.sort((a, b) => a[0].localeCompare(b[0]));
1088+
let paramStrings = params.map(([k, v]) => {
1089+
const paramString = `${k}=${v}`;
1090+
return signature_version >= 2 ? encode_param(paramString) : paramString;
1091+
});
1092+
return paramStrings.join("&");
1093+
}
1094+
1095+
/**
1096+
* Signs API request parameters
1097+
* @param {Object} params_to_sign Parameters to sign
1098+
* @param {string} api_secret API secret
1099+
* @param {string|undefined|null} signature_algorithm Hash algorithm to use ('sha1' or 'sha256')
1100+
* @param {number|undefined|null} signature_version Version of signature algorithm to use:
1101+
* - Version 1: Original behavior without parameter encoding
1102+
* - Version 2+ (default): Includes parameter encoding to prevent parameter smuggling
1103+
* @return {string} Hexadecimal signature
1104+
* @private
1105+
*/
1106+
function api_sign_request(params_to_sign, api_secret, signature_algorithm = null, signature_version = null) {
1107+
if (signature_version == null) {
1108+
signature_version = config().signature_version || 2;
1109+
}
1110+
const to_sign = api_string_to_sign(params_to_sign, signature_version);
1111+
const algo = signature_algorithm || config().signature_algorithm || DEFAULT_SIGNATURE_ALGORITHM;
1112+
return compute_hash(to_sign + api_secret, algo, 'hex');
11221113
}
11231114

11241115
/**
@@ -1139,13 +1130,9 @@ function compute_hash(input, signature_algorithm, encoding) {
11391130

11401131
function clear_blank(hash) {
11411132
let filtered_hash = {};
1142-
entries(hash).filter(
1143-
([k, v]) => utils.present(v)
1144-
).forEach(
1145-
([k, v]) => {
1146-
filtered_hash[k] = v.filter ? v.filter(x => x) : v;
1147-
}
1148-
);
1133+
entries(hash).filter(([k, v]) => utils.present(v)).forEach(([k, v]) => {
1134+
filtered_hash[k] = v.filter ? v.filter(x => x) : v;
1135+
});
11491136
return filtered_hash;
11501137
}
11511138

@@ -1163,8 +1150,10 @@ function merge(hash1, hash2) {
11631150
function sign_request(params, options = {}) {
11641151
let apiKey = ensureOption(options, 'api_key');
11651152
let apiSecret = ensureOption(options, 'api_secret');
1153+
let signature_algorithm = options.signature_algorithm;
1154+
let signature_version = options.signature_version;
11661155
params = exports.clear_blank(params);
1167-
params.signature = exports.api_sign_request(params, apiSecret);
1156+
params.signature = exports.api_sign_request(params, apiSecret, signature_algorithm, signature_version);
11681157
params.api_key = apiKey;
11691158
return params;
11701159
}
@@ -1556,9 +1545,7 @@ function generate_responsive_breakpoints_string(breakpoints) {
15561545
let breakpoint_settings = breakpoints[j];
15571546
if (breakpoint_settings != null) {
15581547
if (breakpoint_settings.transformation) {
1559-
breakpoint_settings.transformation = utils.generate_transformation_string(
1560-
clone(breakpoint_settings.transformation)
1561-
);
1548+
breakpoint_settings.transformation = utils.generate_transformation_string(clone(breakpoint_settings.transformation));
15621549
}
15631550
}
15641551
}
@@ -1568,11 +1555,9 @@ function generate_responsive_breakpoints_string(breakpoints) {
15681555
function build_streaming_profiles_param(options = {}) {
15691556
let params = pickOnlyExistingValues(options, "display_name", "representations");
15701557
if (isArray(params.representations)) {
1571-
params.representations = JSON.stringify(params.representations.map(
1572-
r => ({
1573-
transformation: utils.generate_transformation_string(r.transformation)
1574-
})
1575-
));
1558+
params.representations = JSON.stringify(params.representations.map(r => ({
1559+
transformation: utils.generate_transformation_string(r.transformation)
1560+
})));
15761561
}
15771562
return params;
15781563
}
@@ -1597,9 +1582,7 @@ function hashToParameters(hash) {
15971582
* @return {string} A URI query string.
15981583
*/
15991584
function hashToQuery(hash) {
1600-
return hashToParameters(hash).map(
1601-
([key, value]) => `${querystring.escape(key)}=${querystring.escape(value)}`
1602-
).join('&');
1585+
return hashToParameters(hash).map(([key, value]) => `${querystring.escape(key)}=${querystring.escape(value)}`).join('&');
16031586
}
16041587

16051588
/**
@@ -1742,3 +1725,27 @@ Object.assign(module.exports, {
17421725
keys: source => Object.keys(source),
17431726
ensurePresenceOf
17441727
});
1728+
1729+
/**
1730+
* Verifies an API response signature for a given public_id and version.
1731+
* Always uses signature version 1 for backward compatibility, matching the Ruby SDK.
1732+
* @param {string} public_id
1733+
* @param {string|number} version
1734+
* @param {string} signature
1735+
* @returns {boolean}
1736+
*/
1737+
function verify_api_response_signature(public_id, version, signature) {
1738+
const api_secret = config().api_secret;
1739+
const expected = exports.api_sign_request(
1740+
{
1741+
public_id,
1742+
version
1743+
},
1744+
api_secret,
1745+
null,
1746+
1
1747+
);
1748+
return signature === expected;
1749+
}
1750+
1751+
exports.verify_api_response_signature = verify_api_response_signature;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"author": "Cloudinary <[email protected]>",
33
"name": "cloudinary",
44
"description": "Cloudinary NPM for node.js integration",
5-
"version": "2.6.0",
5+
"version": "2.7.0",
66
"homepage": "https://cloudinary.com",
77
"license": "MIT",
88
"repository": {

test/integration/api/admin/api_spec.js

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,33 +1331,7 @@ describe("api", function () {
13311331
});
13321332
});
13331333
describe('.restore', function () {
1334-
this.timeout(TIMEOUT.MEDIUM);
1335-
1336-
const publicId = "api_test_restore" + UNIQUE_JOB_SUFFIX_ID;
1337-
before(() => uploadImage({
1338-
public_id: publicId,
1339-
backup: true,
1340-
tags: UPLOAD_TAGS
1341-
}).then(wait(2000)).then(() => cloudinary.v2.api.resource(publicId)).then((resource) => {
1342-
expect(resource).not.to.be(null);
1343-
expect(resource.bytes).to.eql(3381);
1344-
return cloudinary.v2.api.delete_resources(publicId);
1345-
}).then(() => cloudinary.v2.api.resource(publicId)).then((resource) => {
1346-
expect(resource).not.to.be(null);
1347-
expect(resource.bytes).to.eql(0);
1348-
expect(resource.placeholder).to.eql(true);
1349-
}));
1350-
it('should restore a deleted resource', () => cloudinary.v2.api.restore(publicId).then((response) => {
1351-
let info = response[publicId];
1352-
expect(info).not.to.be(null);
1353-
expect(info.bytes).to.eql(3381);
1354-
return cloudinary.v2.api.resource(publicId);
1355-
}).then((resource) => {
1356-
expect(resource).not.to.be(null);
1357-
expect(resource.bytes).to.eql(3381);
1358-
}));
1359-
1360-
it('should restore different versions of a deleted asset', async function () {
1334+
it.skip('should restore different versions of a deleted asset', async function () {
13611335
this.timeout(TIMEOUT.LARGE);
13621336

13631337
// Upload the same file twice (upload->delete->upload->delete)
@@ -1401,12 +1375,12 @@ describe("api", function () {
14011375
const secondAssetVersion = getVersionsResp.versions[1].version_id;
14021376

14031377
// Restore first version, ensure it's equal to the upload size
1404-
await wait(1000)();
1378+
await wait(2000)();
14051379
const firstVerRestore = await API_V2.restore([PUBLIC_ID_BACKUP_1], {versions: [firstAssetVersion]});
14061380
expect(firstVerRestore[PUBLIC_ID_BACKUP_1].bytes).to.eql(firstUpload.bytes);
14071381

14081382
// Restore second version, ensure it's equal to the upload size
1409-
await wait(1000)();
1383+
await wait(2000)();
14101384
const secondVerRestore = await API_V2.restore([PUBLIC_ID_BACKUP_1], {versions: [secondAssetVersion]});
14111385
expect(secondVerRestore[PUBLIC_ID_BACKUP_1].bytes).to.eql(secondUpload.bytes);
14121386

@@ -1415,7 +1389,9 @@ describe("api", function () {
14151389
expect(finalDeleteResp).to.have.property("deleted");
14161390
});
14171391

1418-
it('should restore two different deleted assets', async () => {
1392+
it.skip('should restore two different deleted assets', async () => {
1393+
this.timeout(TIMEOUT.LARGE);
1394+
14191395
// Upload two different files
14201396
const firstUpload = await uploadImage({
14211397
public_id: PUBLIC_ID_BACKUP_1,

0 commit comments

Comments
 (0)