diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d91cd35b8e..212756a28d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Version 25.03.xx Fixes: +- [reports] Non-core reports, such as dashboard reports, now authorize the object they target on create and update - [events] Fix sum chart tooltip displaying the raw floating-point value instead of a rounded number ## Version 25.03.50 diff --git a/plugins/reports/api/api.js b/plugins/reports/api/api.js index 3496a56be04..39a105e220c 100644 --- a/plugins/reports/api/api.js +++ b/plugins/reports/api/api.js @@ -265,38 +265,55 @@ const FEATURE_NAME = 'reports'; //report for arbitrary apps. props.report_type = props.report_type || "core"; + /** + * Insert the authorized report document + * @returns {void} + */ + function insertReport() { + common.db.collection('reports').insert(props, function(err0, result) { + result = result.ops; + if (err0) { + err0 = err0.err; + common.returnMessage(params, 200, err0); + } + else { + plugins.dispatch("/systemlogs", {params: params, action: "reports_create", data: result[0]}); + common.returnMessage(params, 200, "Success"); + } + }); + } + + //apps is authorized whatever the report type. It used to be checked + //only for core reports, so a non-core report could be stored with an + //arbitrary apps list, and a later update flipping report_type to "core" + //would start using that list without it ever having been checked. The + //dashboards drawer hides the app picker, so a legitimate non-core report + //carries an empty apps and is unaffected by this. + if (!appsArePermitted(params, props.apps)) { + return common.returnMessage(params, 401, 'User does not have right to access this information'); + } + if (props.report_type === "core") { if (!props.apps || !Array.isArray(props.apps) || props.apps.length === 0) { common.returnMessage(params, 400, 'Invalid or missing apps'); return; } - - if (!params.member.global_admin) { - let allowedApps = (getAdminApps(params.member) || []) - .concat(getUserAppsForFeaturePermission(params.member, FEATURE_NAME, 'r') || []); - if (typeof params.member.permission === "undefined" && Array.isArray(params.member.user_of)) { - allowedApps = allowedApps.concat(params.member.user_of); - } - let notPermitted = props.apps.some(function(appId) { - return allowedApps.indexOf(appId) === -1; - }); - if (notPermitted) { + insertReport(); + } + else { + //a non-core report targets another plugin's object - a + //"dashboards" report renders the dashboard named in + //props.dashboards - and only that plugin knows whether the + //member may use it. Previously nothing checked this, so any + //member with reports-create rights could schedule a report + //against an arbitrary dashboard id. + validateNonCoreUser(params, props, function(authErr, authorized) { + if (!authorized) { return common.returnMessage(params, 401, 'User does not have right to access this information'); } - } + insertReport(); + }); } - - common.db.collection('reports').insert(props, function(err0, result) { - result = result.ops; - if (err0) { - err0 = err0.err; - common.returnMessage(params, 200, err0); - } - else { - plugins.dispatch("/systemlogs", {params: params, action: "reports_create", data: result[0]}); - common.returnMessage(params, 200, "Success"); - } - }); }); break; case 'update': @@ -345,35 +362,54 @@ const FEATURE_NAME = 'reports'; //treated as a non-core type to skip the per-app check. var effectiveType = props.report_type || report.report_type || "core"; - if (effectiveType === "core" && typeof props.apps !== "undefined") { - if (!Array.isArray(props.apps) || props.apps.length === 0) { - return common.returnMessage(params, 400, 'Invalid or missing apps'); - } - if (!params.member.global_admin) { - let allowedApps = (getAdminApps(params.member) || []) - .concat(getUserAppsForFeaturePermission(params.member, FEATURE_NAME, 'r') || []); - if (typeof params.member.permission === "undefined" && Array.isArray(params.member.user_of)) { - allowedApps = allowedApps.concat(params.member.user_of); + /** + * Apply the authorized update + * @returns {void} + */ + function applyUpdate() { + common.db.collection('reports').update(recordUpdateOrDeleteQuery(params, id), {$set: props}, function(err_update2) { + if (err_update2) { + err_update2 = err_update2.err; + common.returnMessage(params, 200, err_update2); } - let notPermitted = props.apps.some(function(appId) { - return allowedApps.indexOf(appId) === -1; - }); - if (notPermitted) { - return common.returnMessage(params, 401, 'User does not have right to access this information'); + else { + plugins.dispatch("/systemlogs", {params: params, action: "reports_edited", data: {_id: id, before: report, update: props}}); + common.returnMessage(params, 200, "Success"); } - } + }); } - common.db.collection('reports').update(recordUpdateOrDeleteQuery(params, id), {$set: props}, function(err_update2) { - if (err_update2) { - err_update2 = err_update2.err; - common.returnMessage(params, 200, err_update2); - } - else { - plugins.dispatch("/systemlogs", {params: params, action: "reports_edited", data: {_id: id, before: report, update: props}}); - common.returnMessage(params, 200, "Success"); + //Authorize the apps the report will actually have, which is the + //submitted list when one is sent and the stored list otherwise. + //Checking only the submitted list let an update omit apps to keep a + //stored list that was never checked, and flip report_type to "core" + //so that list started being used. + var effectiveApps = (typeof props.apps !== "undefined") ? props.apps : report.apps; + if (!appsArePermitted(params, effectiveApps)) { + return common.returnMessage(params, 401, 'User does not have right to access this information'); + } + + if (effectiveType === "core") { + if (typeof props.apps !== "undefined" && (!Array.isArray(props.apps) || props.apps.length === 0)) { + return common.returnMessage(params, 400, 'Invalid or missing apps'); } - }); + applyUpdate(); + } + else { + //authorize the merged report, not just the payload: a + //partial update may leave the target (props.dashboards) + //in the stored document, and repointing an existing + //report at another dashboard must be checked too. A copy + //is passed so the authorize flag never reaches $set. + var mergedReport = Object.assign({}, report, props); + mergedReport.report_type = effectiveType; + validateNonCoreUser(params, mergedReport, function(authErr, authorized) { + if (!authorized) { + return common.returnMessage(params, 401, 'User does not have right to access this information'); + } + applyUpdate(); + }); + } }); }); break; @@ -423,6 +459,16 @@ const FEATURE_NAME = 'reports'; return false; } + //Owning a report does not mean still being allowed to read the apps + //it covers. Without this check somebody who lost access to an app + //could send themselves that app's figures on demand, which is the + //most direct form of the problem the scheduled path also has. + if (!appsArePermitted(params, result.apps)) { + log.d("Rejected report send: report " + id + " targets apps the caller may not read"); + common.returnMessage(params, 401, 'User does not have right to access this information'); + return false; + } + reports.sendReport(common.db, id, function(err2) { if (err2) { log.d("Error occurred while sending out report.", err); @@ -455,6 +501,15 @@ const FEATURE_NAME = 'reports'; // TODO: Handle report type check + //renders the report's contents straight into the response, so the + //apps it covers have to be readable by the caller now, not merely + //at the time they created it + if (!appsArePermitted(params, result.apps)) { + log.d("Rejected report preview: report " + id + " targets apps the caller may not read"); + common.returnMessage(params, 401, 'User does not have right to access this information'); + return false; + } + reports.getReport(common.db, result, function(err2, res) { if (err2) { common.returnMessage(params, 200, err2); @@ -494,6 +549,14 @@ const FEATURE_NAME = 'reports'; // TODO: Handle report type check + //same as preview: this hands back the rendered report, so the apps + //it covers have to be readable by the caller now + if (!appsArePermitted(params, result.apps)) { + log.d("Rejected report pdf: report " + id + " targets apps the caller may not read"); + common.returnMessage(params, 401, 'User does not have right to access this information'); + return false; + } + reports.getReport(common.db, result, function(err2, res) { if (err2) { common.returnMessage(params, 200, err2); @@ -540,11 +603,53 @@ const FEATURE_NAME = 'reports'; }); break; case 'status': - validateUpdate(paramsInstance, FEATURE_NAME, function() { + validateUpdate(paramsInstance, FEATURE_NAME, async function() { var params = paramsInstance; const statusList = params.qstring.args; - console.log(statusList, 'status-list'); + //Owning a report is not the same as being allowed to act on the apps it + //targets, so enabling one is authorized against its *stored* apps. + //Without this, somebody who lost access to an app could switch their old + //report back on and keep that app's figures arriving by email. + // + //Switching one off stays allowed. It only reduces what the report does, + //and refusing would leave them unable to stop mail they no longer want. + const enablingIds = Object.keys(statusList || {}).filter(function(id) { + return statusList[id] === true || statusList[id] === "true"; + }); + if (enablingIds.length > 0 && !params.member.global_admin) { + let toEnable = []; + try { + //scoped to records the caller may modify, the same as the write + //below. An id belonging to somebody else must stay the silent + //no-op it already was, rather than returning a 403 that would + //confirm a report with those apps exists. + toEnable = await common.db.collection("reports").find({ + _id: { + $in: enablingIds.map(function(id) { + return common.db.ObjectID(id); + }) + }, + user: common.db.ObjectID(params.member._id) + }, { projection: { apps: 1 } }).toArray(); + } + catch (e) { + log.e("Failed to load reports for a status change", e); + common.returnMessage(params, 500, "Failed to change report status"); + return; + } + const unauthorized = toEnable.filter(function(report) { + return !appsArePermitted(params, report.apps); + }); + if (unauthorized.length > 0) { + log.d("Rejected report status change: report(s) " + + unauthorized.map(function(r) { + return r._id; + }).join(", ") + " target apps the caller may not read"); + common.returnMessage(params, 401, 'User does not have right to access this information'); + return; + } + } var bulk = common.db.collection("reports").initializeUnorderedBulkOp(); for (const id in statusList) { @@ -623,40 +728,63 @@ const FEATURE_NAME = 'reports'; } /** - * validation function for verifing user have permission to access infomation or not for core type of report - * @param {object} params - request params object - * @param {object} props - report related props - * @param {func} cb - callback function - * @return {func} cb - callback function - - function validateCoreUser(params, props, cb) { - var userApps = getUserApps(params.member); - var apps = props.apps; - var isAppUser = apps.every(function(app) { - return userApps && userApps.indexOf(app) > -1; - }); - - if (!params.member.global_admin && !isAppUser) { - return cb(null, false); + * Whether the member may schedule a report for every app in a list. + * + * An empty or absent list is permitted: a non-core report legitimately has no apps, + * since the dashboards drawer hides the app picker. What must never pass is a list + * naming an app the member cannot read, whatever the report type, because a later + * update can flip report_type to "core" and the stored list would then be used. + * + * Legacy members have no permission object and reach apps through user_of, so they + * are allowed for those, matching how /o/reports/all and the alerts endpoints treat + * them. Without it they could not schedule reports for their own apps. + * + * @param {object} params - request params object, for member and global_admin + * @param {Array} apps - app ids the report targets + * @returns {boolean} true when every app is permitted + */ + function appsArePermitted(params, apps) { + if (params.member.global_admin) { + return true; } - else { - return cb(null, true); + if (!Array.isArray(apps) || apps.length === 0) { + return true; } - + let allowedApps = (getAdminApps(params.member) || []) + .concat(getUserAppsForFeaturePermission(params.member, FEATURE_NAME, 'r') || []); + if (typeof params.member.permission === "undefined" && Array.isArray(params.member.user_of)) { + allowedApps = allowedApps.concat(params.member.user_of); + } + allowedApps = allowedApps.map(String); + return apps.every(function(appId) { + return allowedApps.indexOf(appId + "") > -1; + }); } - */ /** - * validation function for verifing user have permission to access infomation or not for not core type of report + * Verify the member may use a non-core report's target. + * + * A non-core report_type names the plugin that owns the report, and that + * plugin authorizes the target through the /report/authorize event: the + * dashboards plugin checks view access to report.dashboards there. This + * fails closed, so a report type whose plugin does not answer the event is + * not authorized. A plugin adding a new report type must implement + * /report/authorize for it. + * + * Core reports are authorized inline against the member's per-app reports + * permission instead, so they never reach this. + * * @param {object} params - request params object - * @param {object} props - report related props - * @param {func} cb - callback function - + * @param {object} props - report related props + * @param {function} cb - callback receiving (err, authorized) + */ function validateNonCoreUser(params, props, cb) { plugins.dispatch("/report/authorize", { params: params, report: props }, function() { var authorized = props.authorized || false; + //the flag is only how the dispatch reports its result back; it must + //not be persisted onto the report document + delete props.authorized; cb(null, authorized); }); } - */ }()); diff --git a/plugins/reports/api/jobs/send.js b/plugins/reports/api/jobs/send.js index 0b893861086..da0cb46042a 100644 --- a/plugins/reports/api/jobs/send.js +++ b/plugins/reports/api/jobs/send.js @@ -60,20 +60,31 @@ class ReportsJob extends job.Job { if (report.last_sent && report.last_sent.hour === hour && report.last_sent.dow === dow && report.last_sent.dom === dom) { return done(); } - reports.getReport(countlyDb, report, function(err2, ob) { - if (!err2) { - reports.send(ob.report, ob.message, function() { - log.d("sent to", ob.report.emails); - countlyDb.collection("reports").updateOne({_id: countlyDb.ObjectID(report._id)}, {$set: {last_sent: {hour: hour, dow: dow, dom: dom}}}, function() { - done(null, null); - }); - }); - } - else { - log.d(err2, ob.report); - done(null, null); + //An app's figures must not keep arriving by email once the member + //the report is scheduled as can no longer read that app. Nothing + //re-checked this after a report was enabled, so one created + //before access was revoked went on sending indefinitely. + //last_sent is deliberately not stamped when a report is skipped, + //so it resumes on its own if the owner's access is restored. + reports.ownerMayStillSend(countlyDb, report, function(maySend) { + if (!maySend) { + return done(null, null); } - }, cache); + reports.getReport(countlyDb, report, function(err2, ob) { + if (!err2) { + reports.send(ob.report, ob.message, function() { + log.d("sent to", ob.report.emails); + countlyDb.collection("reports").updateOne({_id: countlyDb.ObjectID(report._id)}, {$set: {last_sent: {hour: hour, dow: dow, dom: dom}}}, function() { + done(null, null); + }); + }); + } + else { + log.d(err2, ob.report); + done(null, null); + } + }, cache); + }); } else { done(null, null); diff --git a/plugins/reports/api/reports.js b/plugins/reports/api/reports.js index 6a3ffd7089c..7a4705359c1 100644 --- a/plugins/reports/api/reports.js +++ b/plugins/reports/api/reports.js @@ -16,8 +16,11 @@ var reportsInstance = {}, versionInfo = require('../../../frontend/express/version.info'), countlyConfig = require('../../../frontend/express/config.js'), countlyApiConfig = require('./../../../api/config', 'dont-enclose'), + rights = require('../../../api/utils/rights.js'), pdf = require('../../../api/utils/pdf'); +const FEATURE_NAME = 'reports'; + countlyConfig.passwordSecret || ""; /** @@ -70,26 +73,120 @@ var metricProps = { }; (function(reports) { let _periodObj = null; + /** + * Whether the member a report is scheduled as may still read every app it covers. + * + * The endpoints authorize a request, but nothing re-checked anything once a report + * was scheduled, so one created before access was revoked kept mailing that app's + * figures indefinitely with no further action by anybody. + * + * A report with no apps is a non-core report (a dashboard, say), whose target is + * authorized by its own plugin through /report/authorize rather than per app, so + * there is nothing to judge here and it passes. + * + * @param {object} member - the member the report is scheduled as + * @param {Array} apps - app ids the report covers + * @returns {boolean} true when the report may still be sent + */ + function ownerMayStillReadApps(member, apps) { + if (!member) { + return false; + } + if (member.global_admin) { + return true; + } + if (!Array.isArray(apps) || apps.length === 0) { + return true; + } + //members created before the permission object reach apps through user_of, and + //the right helpers fall through to admin_of alone, so without this their + //reports would stop arriving + const legacy = (typeof member.permission === "undefined" && Array.isArray(member.user_of)) + ? member.user_of.map(String) + : []; + return apps.every(function(appId) { + return rights.hasReadRight(FEATURE_NAME, appId + "", member) || legacy.indexOf(appId + "") > -1; + }); + } + + /** + * Whether a stored report may still be sent, judged by its owner's current access. + * + * Used by both the scheduled job and the send-now path, which reach the renderer by + * different routes and would otherwise disagree. + * + * @param {object} db - database connection + * @param {object} report - the stored report + * @param {function} cb - cb(maySend), called with false when it must not be sent + * @returns {void} + */ + reports.ownerMayStillSend = function(db, report, cb) { + if (!report) { + return cb(false); + } + db.collection('members').findOne({_id: db.ObjectID(report.user + "")}, function(memberErr, owner) { + if (memberErr) { + log.e("Could not resolve the owner of report " + report._id + ", not sending", memberErr); + return cb(false); + } + //an owner that no longer exists is left to getReport, which falls back to a + //global admin. That fallback predates this check and silently raises the + //access a report renders with, but changing it here would stop reports that + //have worked for years, so it is left alone and only noted. + if (!owner) { + log.w("Report " + report._id + " has no resolvable owner, so its access cannot be checked. Sending anyway."); + return cb(true); + } + if (!ownerMayStillReadApps(owner, report.apps)) { + log.d("Not sending report " + report._id + ": its owner no longer has access to the apps it covers"); + return cb(false); + } + cb(true); + }); + }; + reports.sendReport = function(db, id, callback) { reports.loadReport(db, id, function(err, report) { if (err) { return callback(err, null); } - reports.getReport(db, report, function(err2, ob) { - if (!err2) { - reports.send(ob.report, ob.message, function() { - if (callback) { - callback(err2, ob.message); - } - }); - } - else if (callback) { - callback(err2, ob.message); + if (!report) { + return callback("Report not found", null); + } + //Do not keep mailing an app's figures to somebody who may no longer read it. + //Checked here rather than in getReport, so that preview and pdf keep being + //judged against the caller making the request instead. + reports.ownerMayStillSend(db, report, function(maySend) { + if (!maySend) { + return callback(null, null); } + reports.sendLoadedReport(db, report, callback); }); }); }; + /** + * Render and send a report that has already been loaded and authorized. + * @param {object} db - database connection + * @param {object} report - the stored report + * @param {function} callback - callback + * @returns {void} + */ + reports.sendLoadedReport = function(db, report, callback) { + reports.getReport(db, report, function(err2, ob) { + if (!err2) { + reports.send(ob.report, ob.message, function() { + if (callback) { + callback(err2, ob.message); + } + }); + } + else if (callback) { + callback(err2, ob.message); + } + }); + }; + reports.loadReport = function(db, id, callback) { db.collection('reports').findOne({_id: db.ObjectID(id)}, function(err, report) { if (callback) { diff --git a/plugins/reports/tests.js b/plugins/reports/tests.js index b6d0db622b8..f1158d6e18b 100644 --- a/plugins/reports/tests.js +++ b/plugins/reports/tests.js @@ -316,3 +316,622 @@ describe('Testing Reports', function() { }); }); + +// Regression tests for authorization of non-core report targets. +// +// A non-core report_type names the plugin that owns the report, and that plugin +// authorizes the target through /report/authorize: the dashboards plugin checks +// view access to the dashboard a "dashboards" report renders. That caller was +// commented out, so a member with reports rights could schedule a report against +// any dashboard id, including a private dashboard belonging to someone else. +// +// These go through the real HTTP endpoints with a real non-admin member, so the +// whole path is exercised: validateCreate, the report_type branch, the +// /report/authorize dispatch into the dashboards plugin, and the insert. + +describe('Testing Reports non-core authorization', function() { + var API_KEY_ADMIN = ""; + var APP_ID = ""; + var adminDashboardId = ""; + var memberDashboardId = ""; + var sharedDashIdForCleanup = ""; + var memberApiKey = ""; + var memberUserId = ""; + var VICTIM_APP_ID = ""; + var uniq = Date.now(); + + /** + * Build a dashboards-type report config + * @param {string} dashboardId - dashboard the report renders + * @returns {object} report config + */ + function dashboardReport(dashboardId) { + return { + title: "noncore-authz-" + uniq, + report_type: "dashboards", + dashboards: dashboardId, + emails: ["a@abc.com"], + frequency: "daily", + timezone: "Europe/Tirane", + hour: 4, + minute: 0, + sendPdf: true + }; + } + + it('should create a private dashboard as admin', function(done) { + API_KEY_ADMIN = testUtils.get("API_KEY_ADMIN"); + APP_ID = testUtils.get("APP_ID"); + request.get('/i/dashboards/create?api_key=' + API_KEY_ADMIN + '&name=ReportsPrivateDash' + uniq + '&share_with=none') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + adminDashboardId = JSON.parse(res.text); + should.exist(adminDashboardId); + done(); + }); + }); + + it('should create a non-admin member with reports rights on the test app', function(done) { + var permission = { + _: {a: [], u: [[APP_ID]]}, + c: {}, + r: {}, + u: {}, + d: {} + }; + permission.c[APP_ID] = {all: false, allowed: {reports: true}}; + permission.r[APP_ID] = {all: false, allowed: {reports: true}}; + permission.u[APP_ID] = {all: false, allowed: {reports: true}}; + var userParams = { + full_name: "reportsmember" + uniq, + username: "reportsmember" + uniq, + password: "p4ssw0rD!", + email: "reportsmember" + uniq + "@mail.test", + permission: permission + }; + request.get('/i/users/create?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify(userParams))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + memberApiKey = res.body.api_key; + memberUserId = res.body._id; + should.exist(memberApiKey); + done(); + }); + }); + + it('should create a second app the member has no rights on', function(done) { + request.get('/i/apps/create?api_key=' + API_KEY_ADMIN + + '&args=' + encodeURIComponent(JSON.stringify({name: "reportsVictim" + uniq, country: "TR", type: "mobile", category: "6", timezone: "Europe/Istanbul"}))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + VICTIM_APP_ID = res.body._id; + should.exist(VICTIM_APP_ID); + done(); + }); + }); + + it('should reject a dashboards report for a dashboard the member cannot view', function(done) { + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(dashboardReport(adminDashboardId)))) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should allow a dashboards report for the member own dashboard', function(done) { + request.get('/i/dashboards/create?api_key=' + memberApiKey + '&name=MemberDash' + uniq + '&share_with=none') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + memberDashboardId = JSON.parse(res.text); + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(dashboardReport(memberDashboardId)))) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + r.body.should.have.property('result', 'Success'); + done(); + }); + }); + }); + + it('should refuse a non-core report naming an app the member cannot read', function(done) { + // apps used to be authorized only for core reports, so a non-core report could + // carry any apps list at all + var sneaky = Object.assign({}, dashboardReport(memberDashboardId), {apps: [VICTIM_APP_ID]}); + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(sneaky))) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should refuse to convert a stored report onto an app the member cannot read', function(done) { + // the second half of the chain: store apps while the type is non-core, then flip + // report_type to core with apps omitted so the stored list survives unchecked + var report = Object.assign({}, dashboardReport(memberDashboardId), {title: "convert-" + uniq}); + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(report))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + request.get('/o/reports/all?api_key=' + memberApiKey + '&app_id=' + APP_ID) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + var created = (r.body || []).filter(function(x) { + return x.title === "convert-" + uniq; + })[0]; + should.exist(created); + // an admin plants the unauthorized apps list directly, standing in + // for the first half of the chain now that create refuses it + request.get('/i/reports/update?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify({_id: created._id, apps: [VICTIM_APP_ID]}))) + .end(function() { + request.get('/i/reports/update?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify({_id: created._id, report_type: "core"}))) + .expect(401) + .end(function(e2) { + if (e2) { + return done(e2); + } + done(); + }); + }); + }); + }); + }); + + it('should allow a dashboards report for a dashboard shared with the member', function(done) { + // Dashboard permissions are deliberately separate from app permissions: a + // dashboard can be shared with a member who has no access to the apps its + // widgets reference, and they are meant to be able to view it and schedule a + // report for it. This is the same admin-owned dashboard the member was + // refused above, so the only thing that changes here is the share, which is + // what proves authorization follows the share and not app rights. + var sharedDashboardId = ""; + request.get('/i/dashboards/create?api_key=' + API_KEY_ADMIN + + '&name=ReportsSharedDash' + uniq + + '&share_with=selected-users' + + '&shared_email_view=' + encodeURIComponent(JSON.stringify(["reportsmember" + uniq + "@mail.test"]))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + sharedDashboardId = JSON.parse(res.text); + should.exist(sharedDashboardId); + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(dashboardReport(sharedDashboardId)))) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + r.body.should.have.property('result', 'Success'); + sharedDashIdForCleanup = sharedDashboardId; + done(); + }); + }); + }); + + it('should not persist the authorize flag on the stored report', function(done) { + request.get('/o/reports/all?api_key=' + memberApiKey + '&app_id=' + APP_ID) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + res.body.forEach(function(r) { + r.should.not.have.property('authorized'); + }); + done(); + }); + }); + + it('should still allow a core report for an app the member has rights on', function(done) { + var coreReport = Object.assign({}, newReport, {apps: [APP_ID], title: "noncore-core-control-" + uniq}); + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_ID + + '&args=' + encodeURIComponent(JSON.stringify(coreReport))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + res.body.should.have.property('result', 'Success'); + done(); + }); + }); + + after(function(done) { + // remove everything this block created: the reports it scheduled, both + // dashboards, and the member + request.get('/o/reports/all?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_ID) + .end(function(listErr, listRes) { + var created = ((listRes && listRes.body) || []).filter(function(r) { + return typeof r.title === "string" && r.title.indexOf("" + uniq) > -1; + }); + var pending = created.length; + /** + * Delete the dashboards and the member once reports are gone + * @returns {void} + */ + function cleanupRest() { + var dashboards = [adminDashboardId, memberDashboardId, sharedDashIdForCleanup].filter(Boolean); + if (VICTIM_APP_ID) { + request.get('/i/apps/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({app_id: VICTIM_APP_ID}))).end(function() {}); + } + /** + * Delete the dashboards one at a time, then the member + * @param {number} i - index into dashboards + * @returns {void} + */ + function deleteDashboard(i) { + if (i >= dashboards.length) { + return request.get('/i/users/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({user_ids: [memberUserId]}))) + .end(function() { + done(); + }); + } + request.get('/i/dashboards/delete?api_key=' + API_KEY_ADMIN + '&dashboard_id=' + dashboards[i]) + .end(function() { + deleteDashboard(i + 1); + }); + } + deleteDashboard(0); + } + if (!pending) { + return cleanupRest(); + } + created.forEach(function(r) { + request.get('/i/reports/delete?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_ID + '&args=' + encodeURIComponent(JSON.stringify({_id: r._id}))) + .end(function() { + pending--; + if (!pending) { + cleanupRest(); + } + }); + }); + }); + }); +}); + + +// Regression tests for what happens to a report after its owner loses access to the app +// it covers. +// +// Owning a report is not the same as being allowed to read the apps it covers, but every +// one of these paths was scoped by owner alone. So a member who scheduled a report while +// they held an app, then lost that access while keeping reports rights elsewhere, could +// still re-enable it, send it to themselves on demand, and render its contents straight +// into a response. +// +// Both directions are covered: the exploit stops working, and everything a member is +// still entitled to do keeps working. +describe('Testing Reports after access is revoked', function() { + var API_KEY_ADMIN = ""; + var APP_A = ""; + var APP_B = ""; + var memberApiKey = ""; + var memberId = ""; + var reportOnA = ""; + var reportOnB = ""; + var uniq = Date.now(); + + /** + * Build a core report config for the given apps + * @param {Array} apps - app ids + * @param {string} title - report title + * @returns {object} report config + */ + function reportFor(apps, title) { + return Object.assign({}, newReport, { + title: title, + apps: apps, + emails: ["revoked-" + uniq + "@mail.test"] + }); + } + + /** + * Set the member's reports permissions to exactly the given apps + * @param {Array} apps - app ids to grant + * @param {function} cb - callback + * @returns {void} + */ + function grantReportsOn(apps, cb) { + var permission = {_: {a: [], u: [apps]}, c: {}, r: {}, u: {}, d: {}}; + apps.forEach(function(a) { + permission.c[a] = {all: false, allowed: {reports: true}}; + permission.r[a] = {all: false, allowed: {reports: true}}; + permission.u[a] = {all: false, allowed: {reports: true}}; + permission.d[a] = {all: false, allowed: {reports: true}}; + }); + request.get('/i/users/update?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({user_id: memberId, permission: permission}))) + .end(function() { + cb(); + }); + } + + /** + * Find one of the member's reports by title + * @param {string} title - report title + * @param {function} cb - cb(id) + * @returns {void} + */ + function findReportIdByTitle(title, cb) { + request.get('/o/reports/all?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A) + .end(function(err, res) { + var list = (res && res.body) || []; + var found = list.filter(function(r) { + return r.title === title; + })[0]; + cb(found && found._id); + }); + } + + it('should set up two apps and a member with reports rights on both', function(done) { + API_KEY_ADMIN = testUtils.get("API_KEY_ADMIN"); + APP_A = testUtils.get("APP_ID"); + request.get('/i/apps/create?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({name: "revokedAppB" + uniq, country: "TR", type: "mobile", category: "6", timezone: "Europe/Istanbul"}))) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + APP_B = res.body._id; + should.exist(APP_B); + var permission = {_: {a: [], u: [[APP_A, APP_B]]}, c: {}, r: {}, u: {}, d: {}}; + [APP_A, APP_B].forEach(function(a) { + permission.c[a] = {all: false, allowed: {reports: true}}; + permission.r[a] = {all: false, allowed: {reports: true}}; + permission.u[a] = {all: false, allowed: {reports: true}}; + permission.d[a] = {all: false, allowed: {reports: true}}; + }); + var userParams = { + full_name: "reportrevoked" + uniq, + username: "reportrevoked" + uniq, + password: "p4ssw0rD!", + email: "reportrevoked" + uniq + "@mail.test", + permission: permission + }; + request.get('/i/users/create?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify(userParams))) + .expect(200) + .end(function(e, r) { + if (e) { + return done(e); + } + memberApiKey = r.body.api_key; + memberId = r.body._id; + should.exist(memberApiKey); + done(); + }); + }); + }); + + it('should let the member create a report on each app while they have both', function(done) { + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_A + '&args=' + encodeURIComponent(JSON.stringify(reportFor([APP_A], "revokedA-" + uniq)))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + request.get('/i/reports/create?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify(reportFor([APP_B], "revokedB-" + uniq)))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + findReportIdByTitle("revokedA-" + uniq, function(idA) { + should.exist(idA); + reportOnA = idA + ""; + findReportIdByTitle("revokedB-" + uniq, function(idB) { + should.exist(idB); + reportOnB = idB + ""; + done(); + }); + }); + }); + }); + }); + + it('should revoke access to the first app, keeping reports rights on the second', function(done) { + grantReportsOn([APP_B], function() { + done(); + }); + }); + + // the exploit + + it('should refuse to enable the revoked app report', function(done) { + var status = {}; + status[reportOnA] = true; + request.get('/i/reports/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify(status))) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should refuse to send the revoked app report on demand', function(done) { + request.get('/i/reports/send?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnA}))) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should refuse to render the revoked app report as a preview', function(done) { + // the most direct case: this returns the app's figures in the response body + request.get('/i/reports/preview?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnA}))) + .expect(401) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should not have enabled the report despite the attempts', function(done) { + request.get('/o/reports/all?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var found = ((res.body) || []).filter(function(r) { + return r._id + "" === reportOnA; + })[0]; + should.exist(found); + found.enabled.should.not.equal(true); + done(); + }); + }); + + // the happy paths, which have to keep working + + it('should still let the member enable and disable a report on the app they hold', function(done) { + var on = {}; + on[reportOnB] = true; + request.get('/i/reports/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify(on))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + var off = {}; + off[reportOnB] = false; + request.get('/i/reports/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify(off))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + done(); + }); + }); + }); + + it('should still let the member send and preview a report on the app they hold', function(done) { + request.get('/i/reports/send?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnB}))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + request.get('/i/reports/preview?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnB}))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + done(); + }); + }); + }); + + it('should let the member switch OFF the revoked app report, so they are not stuck with it', function(done) { + // deliberately allowed: disabling only reduces what the report does, and refusing + // would leave them unable to stop mail they no longer want + var off = {}; + off[reportOnA] = false; + request.get('/i/reports/status?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify(off))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should let a global admin send and enable any report', function(done) { + var on = {}; + on[reportOnA] = true; + request.get('/i/reports/status?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&args=' + encodeURIComponent(JSON.stringify(on))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + request.get('/i/reports/send?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnA}))) + .expect(200) + .end(function(e) { + if (e) { + return done(e); + } + done(); + }); + }); + }); + + it('should let the member delete the revoked app report they own', function(done) { + // also deliberately allowed: deleting cannot reach another app's data, and the + // owner needs a way to clean up + request.get('/i/reports/delete?api_key=' + memberApiKey + '&app_id=' + APP_B + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnA}))) + .expect(200) + .end(function(err) { + if (err) { + return done(err); + } + done(); + }); + }); + + after(function(done) { + /** + * remove the member and the extra app once the reports are gone + * @returns {void} + */ + function cleanupRest() { + request.get('/i/users/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({user_ids: [memberId]}))) + .end(function() { + if (!APP_B) { + return done(); + } + request.get('/i/apps/delete?api_key=' + API_KEY_ADMIN + '&args=' + encodeURIComponent(JSON.stringify({app_id: APP_B}))) + .end(function() { + done(); + }); + }); + } + request.get('/i/reports/delete?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnB}))) + .end(function() { + request.get('/i/reports/delete?api_key=' + API_KEY_ADMIN + '&app_id=' + APP_A + '&args=' + encodeURIComponent(JSON.stringify({_id: reportOnA}))) + .end(function() { + cleanupRest(); + }); + }); + }); +});