diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e1f0cd198a8..aed79e725a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -302,7 +302,7 @@ jobs: cd ui-tests npm install xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \ - npm run cy:run:dashboard --headless --no-sandbox --disable-gpu --disable-dev-shm-usage + npm run cy:run:dashboard - name: Upload UI tests artifacts if: ${{ failure() }} @@ -381,7 +381,7 @@ jobs: cd ui-tests npm install xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \ - npm run cy:run:onboarding --headless --no-sandbox --disable-gpu --disable-dev-shm-usage + npm run cy:run:onboarding - name: Upload UI tests artifacts if: ${{ failure() }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abaa70cda6..6bee316055f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ Enterprise fixes: - [nps] Fixed bug in the editor where the "internal name" field was not mandatory - [ratings] Fixed UI bug where "Internal name" was not a mandatory field +Security: +- Fixing minor vulnerability that would allow for unauthorized file upload + ## Version 24.05.16 Fixes: - [core] Replaced "Users" with "Sessions" label on technology home widgets diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js index 3d309a659cf..a026ff95903 100755 --- a/api/utils/requestProcessor.js +++ b/api/utils/requestProcessor.js @@ -7,7 +7,7 @@ const Promise = require('bluebird'); const url = require('url'); const common = require('./common.js'); const countlyCommon = require('../lib/countly.common.js'); -const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate } = require('./rights.js'); +const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getBaseAppFilter } = require('./rights.js'); const authorize = require('./authorizer.js'); const taskmanager = require('./taskmanager.js'); const plugins = require('../../plugins/pluginManager.js'); @@ -2131,7 +2131,7 @@ const processRequest = (params) => { } dbUserHasAccessToCollection(params, params.qstring.collection, (hasAccess) => { - if (hasAccess) { + if (hasAccess || (params.qstring.db === "countly_drill" && params.qstring.collection === "drill_events") || (params.qstring.db === "countly" && params.qstring.collection === "events_data")) { var dbs = { countly: common.db, countly_drill: common.drillDb, countly_out: common.outDb, countly_fs: countlyFs.gridfs.getHandler() }; var db = ""; if (params.qstring.db && dbs[params.qstring.db]) { @@ -2140,6 +2140,23 @@ const processRequest = (params) => { else { db = common.db; } + if (!params.member.global_admin && params.qstring.collection === "drill_events" || params.qstring.collection === "events_data") { + var base_filter = getBaseAppFilter(params.member, params.qstring.db, params.qstring.collection); + if (base_filter && Object.keys(base_filter).length > 0) { + params.qstring.query = params.qstring.query || {}; + for (var key in base_filter) { + if (params.qstring.query[key]) { + params.qstring.query.$and = params.qstring.query.$and || []; + params.qstring.query.$and.push({[key]: base_filter[key]}); + params.qstring.query.$and.push({[key]: params.qstring.query[key]}); + delete params.qstring.query[key]; + } + else { + params.qstring.query[key] = base_filter[key]; + } + } + } + } countlyApi.data.exports.fromDatabase({ db: db, params: params, diff --git a/api/utils/rights.js b/api/utils/rights.js index 6cb3024ff6e..5d431752865 100644 --- a/api/utils/rights.js +++ b/api/utils/rights.js @@ -1083,7 +1083,32 @@ function validateWrite(params, feature, accessType, callback, callbackParam) { }); }); } - +/** + * Creates filter object to filter by member allowed collections + * @param {object} member - members object from params + * @param {string} dbName - database name as string + * @param {string} collectionName - collection Name + * @returns {object} filter object + */ +exports.getBaseAppFilter = function(member, dbName, collectionName) { + var base_filter = {}; + var apps = exports.getUserApps(member); + if (dbName === "countly_drill" && collectionName === "drill_events") { + if (Array.isArray(apps) && apps.length > 0) { + base_filter.a = {"$in": apps}; + } + } + else if (dbName === "countly" && collectionName === "events_data") { + var in_array = []; + if (Array.isArray(apps) && apps.length > 0) { + for (var i = 0; i < apps.length; i++) { + in_array.push(new RegExp("^" + apps[i] + "_.*")); + } + base_filter = {"_id": {"$in": in_array}}; + } + } + return base_filter; +}; /** * Validate user for create access by api_key for provided app_id (both required parameters for the request). * @param {params} params - {@link params} object diff --git a/frontend/express/app.js b/frontend/express/app.js index 6cea6f3f32a..6e846665354 100644 --- a/frontend/express/app.js +++ b/frontend/express/app.js @@ -603,6 +603,10 @@ Promise.all([plugins.dbConnection(countlyConfig), plugins.dbConnection("countly_ app.use(function(req, res, next) { var contentType = req.headers['content-type']; if (req.method.toLowerCase() === 'post' && contentType && contentType.indexOf('multipart/form-data') >= 0) { + if (!req.session?.uid || Date.now() > req.session?.expires) { + res.status(401).send('Unauthorized'); + return; + } var form = new formidable.IncomingForm(); form.uploadDir = __dirname + '/uploads'; form.parse(req, function(err, fields, files) { diff --git a/frontend/express/public/core/app-resolution/templates/app-resolution.html b/frontend/express/public/core/app-resolution/templates/app-resolution.html index 58bda7277a9..9e68c307fa5 100644 --- a/frontend/express/public/core/app-resolution/templates/app-resolution.html +++ b/frontend/express/public/core/app-resolution/templates/app-resolution.html @@ -5,10 +5,7 @@ > diff --git a/frontend/express/public/core/app-version/templates/app-version.html b/frontend/express/public/core/app-version/templates/app-version.html index e6b198adb1b..4ea87e052ef 100644 --- a/frontend/express/public/core/app-version/templates/app-version.html +++ b/frontend/express/public/core/app-version/templates/app-version.html @@ -5,10 +5,7 @@ > diff --git a/frontend/express/public/core/carrier/templates/carrier.html b/frontend/express/public/core/carrier/templates/carrier.html index ce0108ff4ee..5fd82b2867d 100644 --- a/frontend/express/public/core/carrier/templates/carrier.html +++ b/frontend/express/public/core/carrier/templates/carrier.html @@ -6,10 +6,7 @@ > diff --git a/frontend/express/public/core/device-and-type/javascripts/countly.views.js b/frontend/express/public/core/device-and-type/javascripts/countly.views.js index ab4e7279ace..561c67ced3d 100644 --- a/frontend/express/public/core/device-and-type/javascripts/countly.views.js +++ b/frontend/express/public/core/device-and-type/javascripts/countly.views.js @@ -443,6 +443,20 @@ var GridComponent = countlyVue.views.create({ } return val; }, + onWidgetCommand: function(event) { + if (event === 'add' || event === 'manage' || event === 'show') { + this.graphNotesHandleCommand(event); + return; + } + else if (event === 'zoom') { + this.triggerZoom(); + return; + } + else { + this.$emit('command', event); + return; + } + }, } }); diff --git a/frontend/express/public/core/device-and-type/templates/devices-and-types.html b/frontend/express/public/core/device-and-type/templates/devices-and-types.html index 1717de75acc..bfcbcc2229f 100644 --- a/frontend/express/public/core/device-and-type/templates/devices-and-types.html +++ b/frontend/express/public/core/device-and-type/templates/devices-and-types.html @@ -5,10 +5,7 @@ > diff --git a/frontend/express/public/javascripts/countly/countly.helpers.js b/frontend/express/public/javascripts/countly/countly.helpers.js index 01485bf640d..50b3283efae 100644 --- a/frontend/express/public/javascripts/countly/countly.helpers.js +++ b/frontend/express/public/javascripts/countly/countly.helpers.js @@ -397,6 +397,12 @@ if (options.isExternalLink) { window.open(options.url, '_blank', 'noopener,noreferrer'); } + else if (options.download) { + var a = document.createElement('a'); + a.href = options.url; + a.download = options.download; + a.click(); + } else { app.backlinkUrl = options.from; app.backlinkTitle = options.title; diff --git a/frontend/express/public/javascripts/countly/vue/components/dropdown.js b/frontend/express/public/javascripts/countly/vue/components/dropdown.js index e3fa09c3392..18cbdf66ca6 100644 --- a/frontend/express/public/javascripts/countly/vue/components/dropdown.js +++ b/frontend/express/public/javascripts/countly/vue/components/dropdown.js @@ -1,4 +1,4 @@ -/* global jQuery, Vue, ELEMENT, CV */ +/* global jQuery, Vue, ELEMENT, CV, CountlyHelpers */ (function(countlyVue) { @@ -612,7 +612,12 @@ methods: { handleMenuItemClick: function(command, instance) { if (!this.disabled) { - this.$emit('command', command, instance); + if (command && command.url) { + CountlyHelpers.goTo({url: command.url, download: !!command.download, isExternalLink: !!command.isExternalLink}); + } + else { + this.$emit('command', command, instance); + } this.$refs.dropdown.handleClose(); } }, diff --git a/plugins/browser/frontend/public/templates/browser.html b/plugins/browser/frontend/public/templates/browser.html index bf15e73a7c5..d681ae2b9db 100644 --- a/plugins/browser/frontend/public/templates/browser.html +++ b/plugins/browser/frontend/public/templates/browser.html @@ -5,10 +5,7 @@ > diff --git a/plugins/compliance-hub/frontend/public/javascripts/countly.views.js b/plugins/compliance-hub/frontend/public/javascripts/countly.views.js index 4b2bcffd527..ae448f49622 100644 --- a/plugins/compliance-hub/frontend/public/javascripts/countly.views.js +++ b/plugins/compliance-hub/frontend/public/javascripts/countly.views.js @@ -13,9 +13,6 @@ this.$store.dispatch("countlyConsentManager/fetchUserDataResource"); }, methods: { - switchToConsentHistory: function(uid) { - window.location.hash = "#/manage/compliance/history/" + uid; - }, deleteUserData: function(uid) { var self = this; CountlyHelpers.confirm(this.i18n("app-users.delete-userdata-confirm"), "popStyleGreen", function(result) { @@ -77,8 +74,21 @@ downloadExportedData: function(uid) { var win = window.open(countlyCommon.API_PARTS.data.r + "/app_users/download/appUser_" + countlyCommon.ACTIVE_APP_ID + "_" + uid + "?auth_token=" + countlyGlobal.auth_token + "&app_id=" + countlyCommon.ACTIVE_APP_ID, '_blank'); win.focus(); + }, + handleCommand: function(command, uid) { + if (command === "deleteUserData") { + this.deleteUserData(uid); + } + else if (command === "exportUserData") { + this.exportUserData(uid); + } + else if (command === "deleteExport") { + this.deleteExport(uid); + } + else if (command === "downloadExportedData") { + this.downloadExportedData(uid); + } } - } }); var ConsentView = countlyVue.views.create({ diff --git a/plugins/compliance-hub/frontend/public/templates/user.html b/plugins/compliance-hub/frontend/public/templates/user.html index ea020bb1f7c..06b24d2b8f2 100644 --- a/plugins/compliance-hub/frontend/public/templates/user.html +++ b/plugins/compliance-hub/frontend/public/templates/user.html @@ -51,28 +51,12 @@ diff --git a/plugins/crashes/frontend/public/templates/crashgroup.html b/plugins/crashes/frontend/public/templates/crashgroup.html index cf5d30d5d7f..6f30b2c8fd6 100644 --- a/plugins/crashes/frontend/public/templates/crashgroup.html +++ b/plugins/crashes/frontend/public/templates/crashgroup.html @@ -98,20 +98,14 @@

- - - {{i18n("crash_symbolication.should-upload")}} - + + {{i18n("crash_symbolication.should-upload")}} - - - {{i18n("crashes.show-binary-images")}} - + + {{i18n("crashes.show-binary-images")}} - - - {{i18n("crashes.download-stacktrace")}} - + + {{i18n("crashes.download-stacktrace")}} @@ -332,20 +326,14 @@

{{!props.row.symbolicated ? i18n("crash_symbolication.symbolicate_error") : i18n("crash_symbolication.resymbolicate")}} - - - {{i18n("crash_symbolication.should-upload")}} - + + {{i18n("crash_symbolication.should-upload")}} - - - {{i18n('crashes.show-binary-images')}} - + + {{i18n('crashes.show-binary-images')}} - - - {{i18n('crashes.download-stacktrace')}} - + + {{i18n('crashes.download-stacktrace')}} diff --git a/plugins/data-manager/frontend/public/javascripts/countly.views.js b/plugins/data-manager/frontend/public/javascripts/countly.views.js index 746643d1f79..c752e7f9e3b 100644 --- a/plugins/data-manager/frontend/public/javascripts/countly.views.js +++ b/plugins/data-manager/frontend/public/javascripts/countly.views.js @@ -1052,9 +1052,6 @@ else if (event === 'import-schema') { this.importDialogVisible = true; } - else if (event === 'navigate-settings') { - app.navigate("#/manage/configurations/data-manager", true); - } }, onSaveImport: function() { var self = this; diff --git a/plugins/data-manager/frontend/public/templates/events.html b/plugins/data-manager/frontend/public/templates/events.html index f60a5464ccd..4850c69d55f 100644 --- a/plugins/data-manager/frontend/public/templates/events.html +++ b/plugins/data-manager/frontend/public/templates/events.html @@ -33,9 +33,7 @@ {{i18n('data-manager.regenerate')}} {{i18n('data-manager.export-schema')}} {{i18n('data-manager.import-schema')}} - - {{i18n('plugins.configs')}} - + {{i18n('plugins.configs')}} diff --git a/plugins/dbviewer/api/api.js b/plugins/dbviewer/api/api.js index 5ea7f771fef..46c45a19425 100644 --- a/plugins/dbviewer/api/api.js +++ b/plugins/dbviewer/api/api.js @@ -5,19 +5,88 @@ var common = require('../../../api/utils/common.js'), countlyFs = require('../../../api/utils/countlyFs.js'), _ = require('underscore'), taskManager = require('../../../api/utils/taskmanager.js'), - { getCollectionName, dbUserHasAccessToCollection, dbLoadEventsData, validateUser, getUserApps, validateGlobalAdmin, hasReadRight } = require('../../../api/utils/rights.js'), + { getCollectionName, dbUserHasAccessToCollection, dbLoadEventsData, validateUser, getUserApps, validateGlobalAdmin, hasReadRight, getBaseAppFilter } = require('../../../api/utils/rights.js'), exported = {}; const { MongoInvalidArgumentError } = require('mongodb'); const { EJSON } = require('bson'); const FEATURE_NAME = 'dbviewer'; +const whiteListedAggregationStages = { + "$addFields": true, + "$bucket": true, + "$bucketAuto": true, + //"$changeStream": false, + //"$changeStreamSplitLargeEvents": false, + //"$collStats": false, + "$count": true, + //"$currentOp": false, + "$densify": true, + //"$documents": false + "$facet": true, + "$fill": true, + "$geoNear": true, + "$graphLookup": true, + "$group": true, + //"$indexStats": false, + "$limit": true, + //"$listLocalSessions": false + //"$listSampledQueries": false + //"$listSearchIndexes": false + //"$listSessions": false + //"$lookup": false + "$match": true, + //"$merge": false + //"$mergeCursors": false + //"$out": false + //"$planCacheStats": false, + "$project": true, + "$querySettings": true, + "$redact": true, + "$replaceRoot": true, + "$replaceWith": true, + "$sample": true, + "$search": true, + "$searchMeta": true, + "$set": true, + "$setWindowFields": true, + //"$sharedDataDistribution": false, + "$skip": true, + "$sort": true, + "$sortByCount": true, + //"$unionWith": false, + "$unset": true, + "$unwind": true, + "$vectorSearch": true //atlas specific +}; var spawn = require('child_process').spawn, child; + (function() { plugins.register("/permissions/features", function(ob) { ob.features.push(FEATURE_NAME); }); + /** + * Function removes not allowed aggregation stages from the pipeline + * @param {array} aggregation - current aggregation pipeline + * @returns {object} changes - object with information which operations were removed + */ + function escapeNotAllowedAggregationStages(aggregation) { + var changes = {}; + for (var z = 0; z < aggregation.length; z++) { + for (var key in aggregation[z]) { + if (!whiteListedAggregationStages[key]) { + changes[key] = true; + delete aggregation[z][key]; + } + } + if (Object.keys(aggregation[z]).length === 0) { + aggregation.splice(z, 1); + z--; + } + } + return changes; + } /** * @api {get} /o/db Access database @@ -179,6 +248,25 @@ var spawn = require('child_process').spawn, filter = {}; } + var base_filter = {}; + if (!params.member.global_admin) { + base_filter = getBaseAppFilter(params.member, dbNameOnParam, params.qstring.collection); + } + + if (base_filter && Object.keys(base_filter).length > 0) { + for (var key in base_filter) { + if (filter[key]) { + filter.$and = filter.$and || []; + filter.$and.push({[key]: base_filter[key]}); + filter.$and.push({[key]: filter[key]}); + delete filter[key]; + } + else { + filter[key] = base_filter[key]; + } + } + } + if (dbs[dbNameOnParam]) { try { var cursor = dbs[dbNameOnParam].collection(params.qstring.collection).find(filter, { projection }); @@ -191,6 +279,7 @@ var spawn = require('child_process').spawn, common.returnMessage(params, 400, "Invalid collection name: Collection names can not contain '$' or other invalid characters"); } else { + log.e(error); common.returnMessage(params, 500, "An unexpected error occurred."); } return false; @@ -291,7 +380,7 @@ var spawn = require('child_process').spawn, async.each(results, function(col, done) { if (col.collectionName.indexOf("system.indexes") === -1 && col.collectionName.indexOf("sessions_") === -1) { userHasAccess(params, col.collectionName, params.qstring.app_id, function(hasAccess) { - if (hasAccess) { + if (hasAccess || col.collectionName === "events_data" || col.collectionName === "drill_events") { ob = parseCollectionName(col.collectionName, lookup); db.collections[ob.pretty] = ob.name; } @@ -318,8 +407,9 @@ var spawn = require('child_process').spawn, * Get aggregated result by the parameter on the url * @param {string} collection - collection will be applied related query * @param {object} aggregation - aggregation object + * @param {object} changes - object referencing removed stages from pipeline * */ - function aggregate(collection, aggregation) { + function aggregate(collection, aggregation, changes) { if (params.qstring.iDisplayLength) { aggregation.push({ "$limit": parseInt(params.qstring.iDisplayLength) }); } @@ -339,6 +429,10 @@ var spawn = require('child_process').spawn, else if (collection === 'auth_tokens') { aggregation.splice(addProjectionAt, 0, {"$addFields": {"_id": "***redacted***"}}); } + else if ((collection === "events_data" || collection === "drill_events") && !params.member.global_admin) { + var base_filter = getBaseAppFilter(params.member, dbNameOnParam, params.qstring.collection); + aggregation.splice(0, 0, {"$match": base_filter}); + } // check task is already running? taskManager.checkIfRunning({ db: dbs[dbNameOnParam], @@ -375,7 +469,7 @@ var spawn = require('child_process').spawn, }, outputData: function(aggregationErr, result) { if (!aggregationErr) { - common.returnOutput(params, { sEcho: params.qstring.sEcho, iTotalRecords: 0, iTotalDisplayRecords: 0, "aaData": result }); + common.returnOutput(params, { sEcho: params.qstring.sEcho, iTotalRecords: 0, iTotalDisplayRecords: 0, "aaData": result, "removed": (changes || {}) }); } else { common.returnMessage(params, 500, aggregationErr); @@ -409,7 +503,12 @@ var spawn = require('child_process').spawn, if (appId) { if (hasReadRight(FEATURE_NAME, appId, parameters.member)) { - return dbUserHasAccessToCollection(parameters, collection, appId, callback); + if (collection === "events_data" || collection === "drill_events") { + return callback(true); + } + else { + return dbUserHasAccessToCollection(parameters, collection, appId, callback); + } } } else { @@ -485,10 +584,14 @@ var spawn = require('child_process').spawn, } else { userHasAccess(params, params.qstring.collection, function(hasAccess) { - if (hasAccess) { + if (hasAccess || params.qstring.collection === "events_data" || params.qstring.collection === "drill_events") { try { let aggregation = EJSON.parse(params.qstring.aggregation); - aggregate(params.qstring.collection, aggregation); + var changes = escapeNotAllowedAggregationStages(aggregation); + if (changes && Object.keys(changes).length > 0) { + log.d("Removed stages from pipeline: ", JSON.stringify(changes)); + } + aggregate(params.qstring.collection, aggregation, changes); } catch (e) { common.returnMessage(params, 500, 'Aggregation object is not valid.'); @@ -508,7 +611,7 @@ var spawn = require('child_process').spawn, } else { userHasAccess(params, params.qstring.collection, function(hasAccess) { - if (hasAccess) { + if (hasAccess || params.qstring.collection === "events_data" || params.qstring.collection === "drill_events") { dbGetCollection(); } else { diff --git a/plugins/dbviewer/frontend/public/javascripts/countly.views.js b/plugins/dbviewer/frontend/public/javascripts/countly.views.js index 2436521175b..4f2a661466e 100644 --- a/plugins/dbviewer/frontend/public/javascripts/countly.views.js +++ b/plugins/dbviewer/frontend/public/javascripts/countly.views.js @@ -534,6 +534,13 @@ if (res.aaData.length) { self.fields = Object.keys(map); } + if (res.removed && typeof res.removed === 'object' && Object.keys(res.removed).length > 0) { + self.removed = CV.i18n('dbviewer.removed-warning') + Object.keys(res.removed).join(", "); + + } + else { + self.removed = ""; + } } if (err) { var message = CV.i18n('dbviewer.server-error'); @@ -559,7 +566,7 @@ } }, updatePath: function(query) { - window.location.hash = "#/manage/db/aggregate/" + this.db + "/" + this.collection + "/" + query; + app.navigate("#/manage/db/aggregate/" + this.db + "/" + this.collection + "/" + query); }, getCollectionName: function() { var self = this; diff --git a/plugins/dbviewer/frontend/public/localization/dbviewer.properties b/plugins/dbviewer/frontend/public/localization/dbviewer.properties index 018c8d96649..eb102853b63 100644 --- a/plugins/dbviewer/frontend/public/localization/dbviewer.properties +++ b/plugins/dbviewer/frontend/public/localization/dbviewer.properties @@ -36,6 +36,7 @@ dbviewer.generate-aggregate-report= Generate aggregate report dbviewer.back-to-dbviewer = Back to DB Viewer dbviewer.invalid-pipeline = Invalid pipeline object, please check pipeline input. dbviewer.server-error = There was a server error. There might be more information in logs. +dbviewer.removed-warning = Some stages are removed from aggregation pipleine. Following stages are allowed only with global admin rights: dbviewer.not-found-data = Couldn't find any results dbviewer.execute-aggregation = Execute Aggregation on {0} dbviewer.prepare-new-aggregation = Prepare New Aggregation diff --git a/plugins/dbviewer/frontend/public/templates/aggregate.html b/plugins/dbviewer/frontend/public/templates/aggregate.html index 63fa391b593..56c711e00ab 100755 --- a/plugins/dbviewer/frontend/public/templates/aggregate.html +++ b/plugins/dbviewer/frontend/public/templates/aggregate.html @@ -25,6 +25,7 @@ +