Skip to content

Commit ec4b65f

Browse files
fix: prevent parameter injection via ampersand in parameter values (#709)
* fix: prevent parameter injection via ampersand in parameter values * fix: correct escaping * fix: longer timeout between restore calls * test: integration test for parameter value escaping * chore: formatting * chore: cleaner code * chore: running all tests * chore: grammar * test: increased wait on restore tests * test: aligned with other cld sdks * feat: signature fix to uploader * feat: verify_api_response_signature * chore: verify_api_response_signature used from utils * chore: whitespaces
1 parent c7f784d commit ec4b65f

File tree

6 files changed

+389
-156
lines changed

6 files changed

+389
-156
lines changed

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;

test/integration/api/admin/api_spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,12 +1374,12 @@ describe("api", function () {
13741374
const secondAssetVersion = getVersionsResp.versions[1].version_id;
13751375

13761376
// Restore first version, ensure it's equal to the upload size
1377-
await wait(1000)();
1377+
await wait(2000)();
13781378
const firstVerRestore = await API_V2.restore([PUBLIC_ID_BACKUP_1], {versions: [firstAssetVersion]});
13791379
expect(firstVerRestore[PUBLIC_ID_BACKUP_1].bytes).to.eql(firstUpload.bytes);
13801380

13811381
// Restore second version, ensure it's equal to the upload size
1382-
await wait(1000)();
1382+
await wait(2000)();
13831383
const secondVerRestore = await API_V2.restore([PUBLIC_ID_BACKUP_1], {versions: [secondAssetVersion]});
13841384
expect(secondVerRestore[PUBLIC_ID_BACKUP_1].bytes).to.eql(secondUpload.bytes);
13851385

0 commit comments

Comments
 (0)