Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
Fixes:
- [events] Fix sum chart tooltip displaying the raw floating-point value instead of a rounded number

Security Fixes:
- [dashboards] Widgets are no longer copied when the copying user has no access to the apps they reference, and widget app ids are validated on widget create and update

## Version 25.03.50
Fixes:
- [star-rating] Fix custom widget logo resolving to the wrong path (mis-detected as the global app logo) after editing a widget
Expand Down
169 changes: 129 additions & 40 deletions plugins/dashboards/api/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,26 @@ plugins.setConfigs("dashboards", {
function insertNewWidgets(dataObj, callback) {
var widgets = dataObj.widgets || [];

//Copying a dashboard makes the copier the owner of the copied
//widgets, and owners have edit rights. A widget is therefore only
//copied when the copier can read every app it points at.
//Otherwise a view-only recipient could rewrite a borrowed
//widget's query and read more of that app than the dashboard
//owner chose to show them. Widgets that reference no apps, such
//as notes, are always copied.
widgets = widgets.filter(function(widget) {
if (typeof widget.apps === "undefined") {
return true;
}

var widgetApps = normalizeAppIds(widget.apps);
if (widgetApps === null) {
return false;
}

return memberHasAccessToAllApps(params.member, widgetApps);
});

if (!widgets.length) {
return callback();
}
Expand Down Expand Up @@ -1201,23 +1221,19 @@ plugins.setConfigs("dashboards", {
widget.contenthtml = sanitizeNote(widget.contenthtml);
}

//Filter out app_ids that current users does not have access to
if (widget.apps && Array.isArray(widget.apps)) {
var user_apps = getUserApps(params.member) || [];
var admin_apps = getAdminApps(params.member) || [];
widget.apps = widget.apps.filter(appId => {
if (params.member.global_admin) {
return true;
}
else if (user_apps && user_apps.indexOf(appId) !== -1) {
return true;
}
else if (admin_apps && admin_apps.indexOf(appId) !== -1) {
return true;
}
return false;
});
//A new widget may only reference apps the creator can read. Reject
//rather than silently strip, so a widget is never stored pointing at
//an app whose data will not be served.
var newWidgetApps = normalizeAppIds(widget.apps);
if (newWidgetApps === null) {
common.returnMessage(params, 400, 'Invalid parameter: widget.apps');
return true;
}
if (!memberHasAccessToAllApps(params.member, newWidgetApps)) {
common.returnMessage(params, 403, 'Not allowed to use one or more of the given apps');
return true;
}
widget.apps = newWidgetApps;

common.db.collection("dashboards").findOne({_id: common.db.ObjectID(dashboardId)}, function(err, dashboard) {
if (err || !dashboard) {
Expand Down Expand Up @@ -1301,25 +1317,18 @@ plugins.setConfigs("dashboards", {
common.returnMessage(params, 400, 'Invalid parameter: widget_id');
return true;
}
//Filter out app_ids that current users does not have access to
if (widget.apps && Array.isArray(widget.apps)) {
var user_apps = getUserApps(params.member) || [];
var admin_apps = getAdminApps(params.member) || [];
widget.apps = widget.apps.filter(appId => {
if (params.member.global_admin) {
return true;
}
else if (user_apps && user_apps.indexOf(appId) !== -1) {
return true;
}
else if (admin_apps && admin_apps.indexOf(appId) !== -1) {
return true;
}
return false;
});
//Normalize the apps list up front so an array-like value such as
//{length: 1, "0": "<app id>"} cannot slip past the access check below
//while still being read through .length/[i] by every consumer.
if (typeof widget.apps !== "undefined") {
var updatedWidgetApps = normalizeAppIds(widget.apps);
if (updatedWidgetApps === null) {
common.returnMessage(params, 400, 'Invalid parameter: widget.apps');
return true;
}
widget.apps = updatedWidgetApps;
}


common.db.collection("dashboards").findOne({_id: common.db.ObjectID(dashboardId), widgets: {$in: [common.db.ObjectID(widgetId)]}}, function(err, dashboard) {
if (err || !dashboard) {
common.returnMessage(params, 400, "Such dashboard and widget combination does not exist.");
Expand All @@ -1336,15 +1345,38 @@ plugins.setConfigs("dashboards", {
unsetQuery.$unset = {"isPluginWidget": ""};
}
if (hasEditAccess) {
common.db.collection("widgets").findAndModify({_id: common.db.ObjectID(widgetId)}, {}, {$set: widget, ...unsetQuery }, {new: false}, function(er, result) {
if (er || !result || !result.value) {
common.returnMessage(params, 500, "Failed to update widget");
//A member with edit access may keep the apps a widget
//already points at, even ones they cannot read - the
//dashboard owner delegated that by sharing with edit
//rights. They may not ADD an app they cannot read,
//which is what would turn a shared widget into a
//window onto an unrelated app.
common.db.collection("widgets").findOne({_id: common.db.ObjectID(widgetId)}, {projection: {apps: 1}}, function(readErr, existingWidget) {
if (readErr || !existingWidget) {
return common.returnMessage(params, 500, "Failed to update widget");
}
else {
plugins.dispatch("/systemlogs", {params: params, action: "widget_edited", data: {before: result.value, update: widget}});
plugins.dispatch("/dashboard/widget/updated", {params: params, widget: {before: result.value, update: widget}});
common.returnMessage(params, 200, 'Success');

if (typeof widget.apps !== "undefined") {
var existingApps = normalizeAppIds(existingWidget.apps) || [];
var addedApps = widget.apps.filter(function(appId) {
return existingApps.indexOf(appId) === -1;
});

if (!memberHasAccessToAllApps(params.member, addedApps)) {
return common.returnMessage(params, 403, 'Not allowed to use one or more of the given apps');
}
}

common.db.collection("widgets").findAndModify({_id: common.db.ObjectID(widgetId)}, {}, {$set: widget, ...unsetQuery }, {new: false}, function(er, result) {
if (er || !result || !result.value) {
common.returnMessage(params, 500, "Failed to update widget");
}
else {
plugins.dispatch("/systemlogs", {params: params, action: "widget_edited", data: {before: result.value, update: widget}});
plugins.dispatch("/dashboard/widget/updated", {params: params, widget: {before: result.value, update: widget}});
common.returnMessage(params, 200, 'Success');
}
});
});
}
else if (hasViewAccess) {
Expand Down Expand Up @@ -1722,6 +1754,63 @@ plugins.setConfigs("dashboards", {
return true;
}

/**
* Function to normalize a widget apps value into an array of app id strings
*
* Only a genuine Array of 24 character hex strings is accepted. An
* array-like object such as {length: 1, "0": "<app id>"} is rejected here,
* because it would otherwise pass an Array.isArray() guard while still
* being consumed through .length/[i] by fetchWidgetApps and by every
* /dashboard/data handler.
*
* @param {Any} apps - widget apps value as supplied or as stored
* @returns {Array|null} array of app id strings, or null if not a valid list
*/
function normalizeAppIds(apps) {
if (!Array.isArray(apps)) {
return null;
}

var appIds = [];

for (var i = 0; i < apps.length; i++) {
var appId = apps[i];

if (typeof appId !== "string" || !/^[a-f0-9]{24}$/i.test(appId)) {
return null;
}

if (appIds.indexOf(appId) === -1) {
appIds.push(appId);
}
}

return appIds;
}

/**
* Function to check whether a member can read every given app
* @param {Object} member - user
* @param {Array} appIds - normalized app id strings
* @returns {Boolean} true if the member has access to all of them
*/
function memberHasAccessToAllApps(member, appIds) {
if (member.global_admin) {
return true;
}

var userApps = getUserApps(member) || [];
var adminApps = getAdminApps(member) || [];

for (var i = 0; i < appIds.length; i++) {
if (userApps.indexOf(appIds[i]) === -1 && adminApps.indexOf(appIds[i]) === -1) {
return false;
}
}

return true;
}

/**
* Function to encode HTML
* @param {String} html - HTML string
Expand Down
Loading
Loading