Skip to content

Commit fdca9cf

Browse files
authored
Merge branch 'master' into QT-426
2 parents f71edd9 + 6f10fc4 commit fdca9cf

26 files changed

Lines changed: 684 additions & 104 deletions

File tree

SECURITY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ The following are out of scope. They may still be reported, and configuration is
3636
9. **Hooks custom-code effects.** The Hooks plugin's custom-code effect runs operator-supplied JavaScript and is being migrated to a stronger isolation model (`isolated-vm`) in an upcoming release, which removes the existing execution surface entirely. Issues that depend on the behaviour of the current custom-code sandbox (for example escaping or abusing the bundled sandbox's built-in helpers) are out of scope. Note that the Hooks plugin already requires an authenticated account with the relevant per-app hooks permission, and the custom-code effect executes code the operator themselves configured.
3737

3838
10. **Instances of a vulnerability class already under active remediation.** Findings that are additional instances of a vulnerability class we are already remediating — including work visible in an open or in-progress pull request, a public branch, or another not-yet-released fix — are considered part of that known, ongoing effort and are not separately eligible. Enumerating sibling occurrences of an issue from our published or in-progress remediation is not an independent discovery. Independently discovered issues remain welcome.
39+
40+
11. **Cross-site scripting (XSS) without a working proof of concept.** XSS reports that do not demonstrate actual script execution in an authenticated dashboard session are out of scope. Pointing at a potential sink (for example a `v-html` binding or a DOM write) is not sufficient on its own, since the value reaching a sink may already be neutralized elsewhere in the request handling or rendering pipeline. XSS with a working end-to-end proof of concept — including DOM-based XSS that originates from the URL or other client-controlled input — is in scope and welcome.

api/parts/data/exports.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,51 @@ function isObjectId(id) {
500500
* @param {string} [options.filename] - name of the file to output, by default auto generated
501501
* @param {function} options.output - callback function where to pass data, by default outputs as file based on type
502502
*/
503+
504+
/**
505+
* Credential fields stripped from privileged collections on export, mirroring
506+
* the DB Viewer read paths so the same data cannot be obtained through export.
507+
*/
508+
var EXPORT_REDACTIONS = {
509+
"members": ["password", "api_key", "two_factor_auth"],
510+
"auth_tokens": ["_id"]
511+
};
512+
513+
/**
514+
* Whether documents from the given collection require credential redaction on export
515+
* @param {string} collection - collection name
516+
* @returns {boolean} true if the collection is redacted on export
517+
*/
518+
exports.redactsExportCollection = function(collection) {
519+
return Object.prototype.hasOwnProperty.call(EXPORT_REDACTIONS, collection);
520+
};
521+
522+
/**
523+
* Remove/mask credential fields from a document before it is written to an export
524+
* @param {string} collection - collection the document belongs to
525+
* @param {object} doc - document to redact (mutated and returned)
526+
* @returns {object} the redacted document
527+
*/
528+
exports.redactExportDoc = function(collection, doc) {
529+
if (!doc || !exports.redactsExportCollection(collection)) {
530+
return doc;
531+
}
532+
var fields = EXPORT_REDACTIONS[collection];
533+
for (var i = 0; i < fields.length; i++) {
534+
if (fields[i] === "_id") {
535+
// _id is normally present (fromDatabase keeps it unless explicitly
536+
// excluded via projection), so mask its value rather than deleting it
537+
if (typeof doc._id !== "undefined") {
538+
doc._id = "***redacted***";
539+
}
540+
}
541+
else {
542+
delete doc[fields[i]];
543+
}
544+
}
545+
return doc;
546+
};
547+
503548
exports.fromDatabase = function(options) {
504549
options.db = options.db || common.db;
505550
options.query = options.query || {};
@@ -566,6 +611,15 @@ exports.fromDatabase = function(options) {
566611
options.query._id = common.db.ObjectID(options.query._id);
567612
}
568613
var cursor = options.db.collection(options.collection).find(options.query, {"projection": options.projection});
614+
// Strip credential material from privileged collections before it
615+
// can reach an export file, mirroring the DB Viewer read paths. This
616+
// runs at the cursor level so it applies regardless of output type
617+
// (json/csv/xls) and regardless of the caller supplied projection.
618+
if (exports.redactsExportCollection(options.collection)) {
619+
cursor = cursor.map(function(doc) {
620+
return exports.redactExportDoc(options.collection, doc);
621+
});
622+
}
569623
if (options.sort) {
570624
cursor.sort(options.sort);
571625
}

api/parts/mgmt/users.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,15 @@ usersApi.createUser = async function(params) {
286286
mail.sendToNewMemberLink(member[0], prid);
287287
});
288288

289+
// Broadcast a credential-free copy of the new member to
290+
// event listeners (e.g. hooks): the member's password hash
291+
// and api_key must never be forwarded into a hook payload.
292+
var createdMemberEventData = Object.assign({}, member[0]);
293+
delete createdMemberEventData.password;
294+
delete createdMemberEventData.api_key;
289295
plugins.dispatch("/i/users/create", {
290296
params: params,
291-
data: member[0]
297+
data: createdMemberEventData
292298
});
293299
delete member[0].password;
294300

@@ -558,10 +564,17 @@ usersApi.updateUser = async function(params) {
558564
common.db.collection('members').findOne({ '_id': common.db.ObjectID(params.qstring.args.user_id) }, function(err2, member) {
559565
if (member && !err2) {
560566
updatedMember._id = params.qstring.args.user_id;
567+
// never forward credentials into event payloads (e.g. hooks)
568+
var updatedMemberEventData = Object.assign({}, updatedMember);
569+
delete updatedMemberEventData.password;
570+
delete updatedMemberEventData.api_key;
571+
var memberBeforeEventData = Object.assign({}, memberBefore);
572+
delete memberBeforeEventData.password;
573+
delete memberBeforeEventData.api_key;
561574
plugins.dispatch("/i/users/update", {
562575
params: params,
563-
data: updatedMember,
564-
member: memberBefore
576+
data: updatedMemberEventData,
577+
member: memberBeforeEventData
565578
});
566579
if (params.qstring.args.send_notification && passwordNoHash) {
567580
mail.sendToUpdatedMember(member, passwordNoHash);
@@ -616,10 +629,14 @@ usersApi.deleteUser = async function(params) {
616629
else {
617630
const user = await common.db.collection('members').findOne({ '_id': common.db.ObjectID(userIds[i]) });
618631
const promisifiedDispatch = function(prms, data) {
632+
// never forward credentials into event payloads (e.g. hooks)
633+
var safeData = Object.assign({}, data);
634+
delete safeData.password;
635+
delete safeData.api_key;
619636
return new Promise((resolve, reject) => {
620637
plugins.dispatch("/i/users/delete", {
621638
params: prms,
622-
data,
639+
data: safeData,
623640
}, async(__, otherPluginResults) => {
624641
const rejectReasons = otherPluginResults.reduce((acc, result) => {
625642
if (result.status === "rejected") {
@@ -813,6 +830,10 @@ usersApi.deleteOwnAccount = function(params) {
813830
};
814831

815832
if (member) {
833+
// never forward credentials into event payloads (e.g. hooks)
834+
var memberEventData = Object.assign({}, member);
835+
delete memberEventData.password;
836+
delete memberEventData.api_key;
816837
if (member.global_admin) {
817838
common.db.collection('members').count({'global_admin': true}, function(err2, count) {
818839
if (err2) {
@@ -825,15 +846,15 @@ usersApi.deleteOwnAccount = function(params) {
825846
else {
826847
plugins.dispatch("/i/users/delete", {
827848
params: params,
828-
data: member
849+
data: memberEventData
829850
}, dispatchDeleteCallback);
830851
}
831852
});
832853
}
833854
else {
834855
plugins.dispatch("/i/users/delete", {
835856
params: params,
836-
data: member
857+
data: memberEventData
837858
}, dispatchDeleteCallback);
838859
}
839860
}

api/utils/common.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,52 @@ common.recordAppKeyUsage = function(app, usedKey) {
463463
}
464464
};
465465

466+
/**
467+
* Resolve an app by an incoming SDK app key for the ingestion/fetch hot path.
468+
* Looks up the accepted-keys array first, then falls back to the current-key
469+
* field. This is deliberately two single-field index lookups rather than one
470+
* {$or:[{key},{keys.key}]} query: MongoDB cannot reliably serve such an $or via
471+
* index union, so on this hot path it degrades to a full collection scan of the
472+
* apps collection. Each lookup here is an indexed equality, and the readBatcher
473+
* caches both (including misses), so the DB sees at most one query per shape per
474+
* cache period.
475+
*
476+
* The keys.key lookup matches every app once its accepted-keys array exists
477+
* (createApp/updateApp populate it, plus a one-time startup backfill), and also
478+
* matches a rotated-away old key during its grace period. The key fallback keeps
479+
* apps that predate the array — or that have not been backfilled yet — fully
480+
* resolvable, so correctness never depends on the backfill having run.
481+
* @param {string} appKey - incoming SDK app key
482+
* @param {function} callback - callback(err, app) with the resolved app or null
483+
* @returns {void}
484+
*/
485+
common.resolveAppByKey = function(appKey, callback) {
486+
var key = appKey + "";
487+
common.readBatcher.getOne("apps", {"keys.key": key}, function(err, app) {
488+
if (app) {
489+
return callback(err, app);
490+
}
491+
common.readBatcher.getOne("apps", {"key": key}, function(err2, app2) {
492+
if (app2) {
493+
return callback(err2, app2);
494+
}
495+
return callback(err2 || err, null);
496+
});
497+
});
498+
};
499+
500+
/**
501+
* Invalidate any cached apps entry that resolveAppByKey may have loaded for an
502+
* incoming SDK key, covering both lookup shapes (keys.key and key).
503+
* @param {string} appKey - incoming SDK app key
504+
* @returns {void}
505+
*/
506+
common.invalidateAppByKey = function(appKey) {
507+
var key = appKey + "";
508+
common.readBatcher.invalidate("apps", {"keys.key": key}, {}, false);
509+
common.readBatcher.invalidate("apps", {"key": key}, {}, false);
510+
};
511+
466512
common.sha512Hash = function(str, addSalt) {
467513
var salt = (addSalt) ? new Date().getTime() : '';
468514
return crypto.createHmac('sha512', salt + '').update(str + '').digest('hex');

api/utils/requestProcessor.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const Promise = require('bluebird');
1212
const url = require('url');
1313
const common = require('./common.js');
1414
const countlyCommon = require('../lib/countly.common.js');
15-
const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getBaseAppFilter } = require('./rights.js');
15+
const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getBaseAppFilter, getAdminApps, getUserAppsForFeaturePermission } = require('./rights.js');
1616
const authorize = require('./authorizer.js');
1717
const taskmanager = require('./taskmanager.js');
1818
const plugins = require('../../plugins/pluginManager.js');
@@ -1867,8 +1867,25 @@ const processRequest = (params) => {
18671867
params.qstring.query.subtask = {$exists: false};
18681868
params.qstring.query.app_id = params.qstring.app_id;
18691869
if (params.qstring.app_ids && params.qstring.app_ids !== "") {
1870-
var ll = params.qstring.app_ids.split(",");
1870+
var ll = params.qstring.app_ids.split(",").map(function(id) {
1871+
return id.trim();
1872+
}).filter(Boolean);
18711873
if (ll.length > 1) {
1874+
// validateRead only checked the single app_id; every app
1875+
// in the multi-app list must also be one the member can read
1876+
if (!params.member.global_admin) {
1877+
var allowedTaskApps = (getAdminApps(params.member) || [])
1878+
.concat(getUserAppsForFeaturePermission(params.member, 'core', 'r') || []);
1879+
if (typeof params.member.permission === "undefined" && Array.isArray(params.member.user_of)) {
1880+
allowedTaskApps = allowedTaskApps.concat(params.member.user_of);
1881+
}
1882+
for (var taskAppIdx = 0; taskAppIdx < ll.length; taskAppIdx++) {
1883+
if (allowedTaskApps.indexOf(ll[taskAppIdx]) === -1) {
1884+
common.returnMessage(params, 401, 'User does not have access to one or more of the requested apps');
1885+
return;
1886+
}
1887+
}
1888+
}
18721889
params.qstring.query.app_id = {$in: ll};
18731890
}
18741891
}
@@ -2092,6 +2109,15 @@ const processRequest = (params) => {
20922109
common.returnMessage(params, 400, 'Missing parameter "collection"');
20932110
return false;
20942111
}
2112+
// query params can be parsed into arrays/objects; force a
2113+
// plain string before any substring check or access lookup
2114+
params.qstring.collection = params.qstring.collection + "";
2115+
// keep the db export surface aligned with DB Viewer: internal
2116+
// index metadata and the dashboard session store are not exportable
2117+
if (params.qstring.collection.indexOf("system.indexes") !== -1 || params.qstring.collection.indexOf("sessions_") !== -1) {
2118+
common.returnMessage(params, 401, 'User does not have access right for this collection');
2119+
return false;
2120+
}
20952121
if (typeof params.qstring.filter === "string") {
20962122
try {
20972123
params.qstring.query = JSON.parse(params.qstring.filter, common.reviver);
@@ -3517,7 +3543,7 @@ const validateAppForWriteAPI = (params, done, try_times) => {
35173543
return done ? done() : false;
35183544
}
35193545

3520-
common.readBatcher.getOne("apps", {$or: [{'key': params.qstring.app_key + ""}, {'keys.key': params.qstring.app_key + ""}]}, (err, app) => {
3546+
common.resolveAppByKey(params.qstring.app_key, (err, app) => {
35213547
if (!app) {
35223548
common.returnMessage(params, 400, 'App does not exist');
35233549
params.cancelRequest = "App not found or no Database connection";
@@ -3566,10 +3592,10 @@ const validateAppForWriteAPI = (params, done, try_times) => {
35663592
if (err1) {
35673593
console.log("Failed to update apps collection " + err1);
35683594
}
3569-
//invalidate using the same query the request loaded the app
3570-
//with (current key OR an accepted old key), so the cache entry
3571-
//for old-key requests is also cleared
3572-
common.readBatcher.invalidate("apps", {$or: [{"key": params.qstring.app_key + ""}, {"keys.key": params.qstring.app_key + ""}]}, {}, false);
3595+
//invalidate both cache shapes the request may have loaded the
3596+
//app with (accepted-keys lookup, then current-key fallback), so
3597+
//the cache entry for old-key requests is also cleared
3598+
common.invalidateAppByKey(params.qstring.app_key);
35733599
});
35743600
}
35753601

@@ -3677,7 +3703,7 @@ const validateAppForFetchAPI = (params, done, try_times) => {
36773703
if (ignorePossibleDevices(params)) {
36783704
return done ? done() : false;
36793705
}
3680-
common.readBatcher.getOne("apps", {$or: [{'key': params.qstring.app_key + ""}, {'keys.key': params.qstring.app_key + ""}]}, (err, app) => {
3706+
common.resolveAppByKey(params.qstring.app_key, (err, app) => {
36813707
if (!app) {
36823708
common.returnMessage(params, 400, 'App does not exist');
36833709
params.cancelRequest = "App not found or no Database connection";

frontend/express/app.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,33 @@ Promise.all([plugins.dbConnection(countlyConfig), plugins.dbConnection("countly_
19811981
//startup (idempotent) so existing apps are covered after an upgrade with no
19821982
//migration script; createApp also ensures it for new apps.
19831983
countlyDb.collection('apps').createIndex({"keys.key": 1}, { background: true }, function() {});
1984+
//backfill the accepted-keys array for apps that predate key rotation, so the
1985+
//SDK app lookup resolves them via the indexed keys.key field on its first
1986+
//query instead of falling back to the current-key field. Idempotent (only
1987+
//touches apps missing the array, and is guarded again per-app at write time)
1988+
//and self-disabling once filled, so it is safe to run on every startup; this
1989+
//process runs in low replica count and the apps collection is small, so no
1990+
//migration script or cross-process coordination is needed. App resolution
1991+
//stays correct before this completes via the current-key fallback in
1992+
//common.resolveAppByKey, so it does not need to gate startup.
1993+
countlyDb.collection('apps').find({ keys: { $exists: false } }, { projection: { key: 1, created_at: 1, last_data: 1 } }).toArray(function(ferr, legacyApps) {
1994+
if (ferr) {
1995+
console.log("Failed to read apps for accepted-keys backfill", ferr);
1996+
return;
1997+
}
1998+
var nowSec = Math.floor(Date.now() / 1000);
1999+
(legacyApps || []).forEach(function(legacyApp) {
2000+
countlyDb.collection('apps').updateOne(
2001+
{ _id: legacyApp._id, keys: { $exists: false } },
2002+
{ $set: { keys: [{ key: legacyApp.key, added_at: legacyApp.created_at || nowSec, last_data: legacyApp.last_data || 0 }] } },
2003+
function(uerr) {
2004+
if (uerr) {
2005+
console.log("Failed to backfill accepted-keys array for app", legacyApp._id, uerr);
2006+
}
2007+
}
2008+
);
2009+
});
2010+
});
19842011
countlyDb.collection('members').createIndex({"api_key": 1}, { unique: true }, function() {});
19852012
countlyDb.collection('members').createIndex({ email: 1 }, { unique: true }, function() {});
19862013
countlyDb.collection('jobs').createIndex({ finished: 1 }, function() {});

0 commit comments

Comments
 (0)