From e0f7b9846b8e30de74747b822e681b9ea8987fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 10:38:32 +0300 Subject: [PATCH] dbviewer: harden aggregation and query handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated DB Viewer hardening. Adds two small, unit-tested guard modules (plugins/dbviewer/api/parts/aggregation_guard.js, query_guard.js) and applies them on both the global-admin and non-admin code paths: Aggregation: - validate stages at every depth, including $facet sub-pipelines (recursive allow-list); require an explicit allow-list match so inherited Object prototype keys (constructor/__proto__) are never treated as allowed - block joins/unions into the redacted collections (members/auth_tokens), block write stages ($out/$merge), and block server-side-JS operators ($function/$accumulator/$where) — all detected by a full deep walk so any (incl. future) nested stage shape is covered - place the members/auth_tokens redaction as the first pipeline stage so no user stage can read the raw fields first Find / document: - restrict projections to strict include/exclude (0/1/booleans), dropping expression / field-path alias values; normalize a non-object projection to {} - treat the _id search term (sSearch) as a literal (regex-escaped, no ReDoS) - scope single-document lookups to the caller's apps (same base filter as the listing path) - redact members.two_factor_auth alongside password/api_key - cap result size (limit / iDisplayLength) and return generic 500 messages instead of raw MongoDB errors Adds unit tests covering the aggregation guard and query guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/dbviewer/api/api.js | 185 +++++----- .../dbviewer/api/parts/aggregation_guard.js | 333 ++++++++++++++++++ plugins/dbviewer/api/parts/query_guard.js | 57 +++ .../plugins.dbviewer.aggregation-guard.js | 151 ++++++++ .../plugins.dbviewer.query-guard.js | 94 +++++ 5 files changed, 734 insertions(+), 86 deletions(-) create mode 100644 plugins/dbviewer/api/parts/aggregation_guard.js create mode 100644 plugins/dbviewer/api/parts/query_guard.js create mode 100644 test/unit-tests/plugins.dbviewer.aggregation-guard.js create mode 100644 test/unit-tests/plugins.dbviewer.query-guard.js diff --git a/plugins/dbviewer/api/api.js b/plugins/dbviewer/api/api.js index 3b80026ea7c..18a350d0f8a 100644 --- a/plugins/dbviewer/api/api.js +++ b/plugins/dbviewer/api/api.js @@ -12,55 +12,14 @@ 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": false — removed: lets attacker pull joined documents from any collection in the same DB, - // bypassing the per-collection access check. Use $lookup instead if cross-collection - // joins are ever needed (currently also disallowed). - "$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 -}; +// upper bound on rows returned per find()/aggregation page, to keep a crafted +// limit/iDisplayLength from requesting an unbounded result set +const MAX_DBVIEWER_LIMIT = 10000; +// Aggregation-stage allow-list and the recursive sanitizer that strips blocked +// stages at every depth (including inside $facet sub-pipelines). Kept in a +// dedicated module so it can be unit-tested in isolation. +const { escapeNotAllowedAggregationStages, findProtectedCollectionJoin, findWriteStage, findServerSideJs } = require('./parts/aggregation_guard.js'); +const { sanitizeProjection, escapeRegExp } = require('./parts/query_guard.js'); var spawn = require('child_process').spawn, child; @@ -68,27 +27,6 @@ var spawn = require('child_process').spawn, 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 @@ -189,11 +127,22 @@ var spawn = require('child_process').spawn, params.qstring.document = common.db.ObjectID(params.qstring.document); } if (dbs[dbNameOnParam]) { - dbs[dbNameOnParam].collection(params.qstring.collection).findOne({ _id: params.qstring.document }, function(err, results) { + // Scope the lookup to the member's apps the same way the + // collection listing does, so a document cannot be fetched + // outside the caller's app scope by supplying its _id. + var docFilter = { _id: params.qstring.document }; + if (!params.member.global_admin) { + var docBaseFilter = getBaseAppFilter(params.member, dbNameOnParam, params.qstring.collection); + if (docBaseFilter && Object.keys(docBaseFilter).length > 0) { + docFilter = { $and: [docBaseFilter, docFilter] }; + } + } + dbs[dbNameOnParam].collection(params.qstring.collection).findOne(docFilter, function(err, results) { if (!err) { if (params.qstring.collection === 'members' && results) { delete results.password; delete results.api_key; + delete results.two_factor_auth; } else if (params.qstring.collection === 'auth_tokens' && results) { if (results._id) { @@ -203,7 +152,8 @@ var spawn = require('child_process').spawn, common.returnOutput(params, objectIdCheck(results) || {}); } else { - common.returnOutput(params, 500, err); + log.e(err); + common.returnMessage(params, 500, "An unexpected error occurred."); } }); } @@ -216,8 +166,19 @@ var spawn = require('child_process').spawn, * Get collection data from db **/ async function dbGetCollection() { - var limit = parseInt(params.qstring.limit || 20); - var skip = parseInt(params.qstring.skip || 0); + // cap page size and guard against NaN so a crafted limit/skip can't + // request an unbounded result set + var limit = parseInt(params.qstring.limit, 10); + if (isNaN(limit) || limit <= 0) { + limit = 20; + } + if (limit > MAX_DBVIEWER_LIMIT) { + limit = MAX_DBVIEWER_LIMIT; + } + var skip = parseInt(params.qstring.skip, 10); + if (isNaN(skip) || skip < 0) { + skip = 0; + } var filter = params.qstring.filter || params.qstring.query || "{}"; var sSearch = params.qstring.sSearch || ""; var projection = params.qstring.project || params.qstring.projection || "{}"; @@ -248,7 +209,9 @@ var spawn = require('child_process').spawn, filter._id = common.db.ObjectID(filter._id); } if (sSearch) { - filter._id = new RegExp(sSearch); + // treat the search term as a literal so a crafted pattern cannot + // cause catastrophic regex backtracking (ReDoS) + filter._id = new RegExp(escapeRegExp(sSearch)); } try { projection = EJSON.parse(projection); @@ -256,6 +219,12 @@ var spawn = require('child_process').spawn, catch (SyntaxError) { projection = {}; } + //EJSON.parse("null") yields null and an array is also typeof + //"object"; normalize anything that isn't a plain object to {} so an + //invalid projection can't reach find() + if (!projection || typeof projection !== 'object' || Array.isArray(projection)) { + projection = {}; + } if (typeof filter !== 'object' || Array.isArray(filter)) { filter = {}; } @@ -265,6 +234,10 @@ var spawn = require('child_process').spawn, //viewer query cannot be abused to execute code on the server common.stripUnsafeMongoOperators(filter); common.stripUnsafeMongoOperators(sort); + //restrict the projection to plain field include/exclude — drop any + //expression / field-path alias (e.g. {x:"$password"}) that could + //compute or rename fields the viewer otherwise removes + sanitizeProjection(projection); var base_filter = {}; if (!params.member.global_admin) { @@ -310,6 +283,7 @@ var spawn = require('child_process').spawn, if (params.qstring.collection === 'members' && doc) { delete doc.password; delete doc.api_key; + delete doc.two_factor_auth; } else if (params.qstring.collection === 'auth_tokens' && doc) { if (doc._id) { @@ -357,7 +331,8 @@ var spawn = require('child_process').spawn, } } catch (err) { - common.returnMessage(params, 500, err); + log.e(err); + common.returnMessage(params, 500, "An unexpected error occurred."); } } } @@ -429,23 +404,25 @@ var spawn = require('child_process').spawn, * */ function aggregate(collection, aggregation, changes) { if (params.qstring.iDisplayLength) { - aggregation.push({ "$limit": parseInt(params.qstring.iDisplayLength) }); + var iDisplayLength = parseInt(params.qstring.iDisplayLength, 10); + if (!isNaN(iDisplayLength) && iDisplayLength > 0) { + aggregation.push({ "$limit": Math.min(iDisplayLength, MAX_DBVIEWER_LIMIT) }); + } } if (!Array.isArray(aggregation)) { common.returnMessage(params, 500, "The aggregation pipeline must be of the type array"); } else { - var addProjectionAt = 0; - if (aggregation[0] && aggregation[0].$match) { - while (aggregation.length > addProjectionAt && aggregation[addProjectionAt].$match) { - addProjectionAt++; - } - } if (collection === 'members') { - aggregation.splice(addProjectionAt, 0, {"$project": {"password": 0, "api_key": 0}}); + // Insert the redaction as the very first stage so no + // user-supplied stage — including a leading $match using + // $expr, or a $project/$group that aliases or references the + // field — can read the raw credential fields before they are + // removed. + aggregation.splice(0, 0, {"$project": {"password": 0, "api_key": 0, "two_factor_auth": 0}}); } else if (collection === 'auth_tokens') { - aggregation.splice(addProjectionAt, 0, {"$addFields": {"_id": "***redacted***"}}); + aggregation.splice(0, 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); @@ -490,7 +467,8 @@ var spawn = require('child_process').spawn, common.returnOutput(params, { sEcho: params.qstring.sEcho, iTotalRecords: 0, iTotalDisplayRecords: 0, "aaData": result, "removed": (changes || {}) }); } else { - common.returnMessage(params, 500, aggregationErr); + log.e(aggregationErr); + common.returnMessage(params, 500, "An unexpected error occurred."); } } }); @@ -593,6 +571,26 @@ var spawn = require('child_process').spawn, if (params.member.global_admin) { try { let aggregation = EJSON.parse(params.qstring.aggregation); + // A join into a redacted collection (members / auth_tokens) + // would return raw credentials, since the redaction only + // applies to the top-level source collection. This is + // blocked even for global admins, who are intentionally + // denied raw api_key / password / tokens via DB Viewer. + var jsOp = findServerSideJs(aggregation); + if (jsOp) { + common.returnMessage(params, 400, 'Aggregation may not use the "' + jsOp + '" operator'); + return true; + } + var writeStage = findWriteStage(aggregation); + if (writeStage) { + common.returnMessage(params, 400, 'Aggregation may not use the "' + writeStage + '" stage'); + return true; + } + var protectedJoin = findProtectedCollectionJoin(aggregation); + if (protectedJoin) { + common.returnMessage(params, 400, 'Aggregation may not join the "' + protectedJoin + '" collection'); + return true; + } aggregate(params.qstring.collection, aggregation); } catch (e) { @@ -605,6 +603,21 @@ var spawn = require('child_process').spawn, if (hasAccess || params.qstring.collection === "events_data" || params.qstring.collection === "drill_events") { try { let aggregation = EJSON.parse(params.qstring.aggregation); + var jsOpRef = findServerSideJs(aggregation); + if (jsOpRef) { + common.returnMessage(params, 400, 'Aggregation may not use the "' + jsOpRef + '" operator'); + return true; + } + var writeStageRef = findWriteStage(aggregation); + if (writeStageRef) { + common.returnMessage(params, 400, 'Aggregation may not use the "' + writeStageRef + '" stage'); + return true; + } + var protectedJoinRef = findProtectedCollectionJoin(aggregation); + if (protectedJoinRef) { + common.returnMessage(params, 400, 'Aggregation may not join the "' + protectedJoinRef + '" collection'); + return true; + } var changes = escapeNotAllowedAggregationStages(aggregation); if (changes && Object.keys(changes).length > 0) { log.d("Removed stages from pipeline: ", JSON.stringify(changes)); diff --git a/plugins/dbviewer/api/parts/aggregation_guard.js b/plugins/dbviewer/api/parts/aggregation_guard.js new file mode 100644 index 00000000000..314113c4be2 --- /dev/null +++ b/plugins/dbviewer/api/parts/aggregation_guard.js @@ -0,0 +1,333 @@ +/** + * @module plugins/dbviewer/api/parts/aggregation_guard + * @description Whitelist of MongoDB aggregation stages the DB Viewer permits for + * non-global users, plus a sanitizer that strips any non-whitelisted stage at + * EVERY depth. + * + * Stages such as $lookup, $graphLookup, $unionWith, $out and $merge are not + * whitelisted because they read from / write to a second collection and would + * bypass the per-collection access check (and the top-level-only members / + * auth_tokens redaction). $facet IS whitelisted, but it carries sub-pipelines; + * if those sub-pipelines are not inspected, a blocked stage (e.g. $lookup) can + * be smuggled in nested under $facet. The sanitizer therefore recurses into + * $facet sub-pipelines so the boundary holds at any depth. + */ + +'use strict'; + +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": false — removed: lets attacker pull joined documents from any collection in the same DB, + // bypassing the per-collection access check. Use $lookup instead if cross-collection + // joins are ever needed (currently also disallowed). + "$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 +}; + +/** + * Sanitize every sub-pipeline a KEPT (whitelisted) stage carries, so a blocked + * stage cannot hide nested inside an allowed pipeline-bearing stage. This is + * driven by structure, not by a hard-coded stage name, so it keeps holding if + * the allow-list ever gains another pipeline-bearing stage: + * - $facet exposes its sub-pipelines as the values of an object + * ({ : [ ...stages ], ... }); each value is sanitized, and a value + * emptied by sanitization is dropped (Mongo rejects an empty $facet + * sub-pipeline). Today $facet is the only allow-listed such stage. + * - any stage that exposes a `.pipeline` array (e.g. $lookup / $unionWith, were + * they ever allow-listed) has that pipeline sanitized. + * @param {string} key - the stage operator (e.g. "$facet") + * @param {*} value - the stage's value + * @param {object} changes - accumulator: keys are removed stage names + * @returns {boolean} true if `value` ended up empty and the stage should be dropped + */ +function sanitizeNestedPipelines(key, value, changes) { + if (!value || typeof value !== "object") { + return false; + } + if (key === "$facet") { + for (var facetName in value) { + if (Array.isArray(value[facetName])) { + sanitizePipeline(value[facetName], changes); + if (value[facetName].length === 0) { + delete value[facetName]; + } + } + } + return Object.keys(value).length === 0; + } + if (Array.isArray(value.pipeline)) { + sanitizePipeline(value.pipeline, changes); + } + return false; +} + +/** + * Recursively remove non-whitelisted stages from an aggregation pipeline, + * descending into the sub-pipelines of any kept pipeline-bearing stage. The + * pipeline is mutated in place. + * @param {Array} pipeline - aggregation pipeline (array of stage objects) + * @param {object} changes - accumulator: keys are removed stage names + * @returns {object} the changes accumulator + */ +function sanitizePipeline(pipeline, changes) { + if (!Array.isArray(pipeline)) { + return changes; + } + for (var z = 0; z < pipeline.length; z++) { + var stage = pipeline[z]; + if (!stage || typeof stage !== "object") { + continue; + } + for (var key in stage) { + // require an explicit `true` so inherited Object.prototype keys + // (constructor, __proto__, …) are never treated as allow-listed + if (whiteListedAggregationStages[key] !== true) { + changes[key] = true; + delete stage[key]; + } + else if (sanitizeNestedPipelines(key, stage[key], changes)) { + // the kept stage's nested content was fully emptied by + // sanitization — drop the now-meaningless stage operator. + delete stage[key]; + } + } + if (Object.keys(stage).length === 0) { + pipeline.splice(z, 1); + z--; + } + } + return changes; +} + +/** + * Remove all not-allowed aggregation stages from the pipeline, at every depth. + * @param {Array} aggregation - current aggregation pipeline (mutated in place) + * @returns {object} changes - object whose keys are the removed stage names + */ +function escapeNotAllowedAggregationStages(aggregation) { + return sanitizePipeline(aggregation, {}); +} + +/** + * Collections whose contents are redacted by DB Viewer (credentials / tokens) + * and which therefore must never be reachable through a join. The redaction is + * only applied when these are the top-level source collection, so a join into + * them (e.g. $lookup { from: "members" }) would return the raw, un-redacted + * documents — including api_key / password / token values. This must hold even + * for global admins, who are intentionally denied raw credentials through DB + * Viewer (the top-level redaction applies to them too). + */ +const PROTECTED_JOIN_COLLECTIONS = { + "members": true, + "auth_tokens": true +}; + +/** + * Collection names a single stage joins / unions from. + * @param {object} stage - one aggregation stage + * @returns {string[]} target collection names referenced by join/union operators + */ +function joinTargetsOf(stage) { + var targets = []; + if (!stage || typeof stage !== "object") { + return targets; + } + if (stage.$lookup && typeof stage.$lookup === "object" && typeof stage.$lookup.from === "string") { + targets.push(stage.$lookup.from); + } + if (stage.$graphLookup && typeof stage.$graphLookup === "object" && typeof stage.$graphLookup.from === "string") { + targets.push(stage.$graphLookup.from); + } + if (stage.$unionWith) { + if (typeof stage.$unionWith === "string") { + targets.push(stage.$unionWith); + } + else if (typeof stage.$unionWith === "object" && typeof stage.$unionWith.coll === "string") { + targets.push(stage.$unionWith.coll); + } + } + return targets; +} + +/** + * Deep-scan an aggregation pipeline node (object/array, at every depth) for a + * join/union into a protected (redacted) collection. Used to block such joins + * regardless of the caller's role, since the per-collection redaction cannot + * follow data pulled in via a join. + * + * This walks ALL nested structures rather than only known sub-pipeline + * locations ($facet / .pipeline), so a join smuggled inside any future stage + * shape is still detected. Detection-only (no mutation), so a blanket deep walk + * is safe here — unlike the stage sanitizer, which must stay targeted to avoid + * mangling expressions. + * @param {*} node - pipeline / stage / expression node (not mutated) + * @returns {string|null} the protected collection name if one is joined, else null + */ +function findProtectedCollectionJoin(node) { + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + var inArr = findProtectedCollectionJoin(node[i]); + if (inArr) { + return inArr; + } + } + return null; + } + if (node && typeof node === "object") { + var targets = joinTargetsOf(node); + for (var t = 0; t < targets.length; t++) { + if (PROTECTED_JOIN_COLLECTIONS[targets[t]] === true) { + return targets[t]; + } + } + for (var key in node) { + if (Object.prototype.hasOwnProperty.call(node, key)) { + var inVal = findProtectedCollectionJoin(node[key]); + if (inVal) { + return inVal; + } + } + } + } + return null; +} + +/** + * Aggregation stages that WRITE to a collection. DB Viewer is a read-only tool, + * so these must never run — including on the global-admin path, which otherwise + * skips the stage allow-list. + */ +const WRITE_STAGES = { + "$out": true, + "$merge": true +}; + +/** + * Deep-scan an aggregation pipeline node (object/array, at every depth) for a + * write stage ($out / $merge). Detection-only, so a blanket deep walk is safe + * and stays correct for any (incl. future) nested stage shape. + * @param {*} node - pipeline / stage / expression node (not mutated) + * @returns {string|null} the write stage name if present, else null + */ +function findWriteStage(node) { + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + var inArr = findWriteStage(node[i]); + if (inArr) { + return inArr; + } + } + return null; + } + if (node && typeof node === "object") { + for (var key in node) { + if (Object.prototype.hasOwnProperty.call(node, key)) { + if (WRITE_STAGES[key] === true) { + return key; + } + var inVal = findWriteStage(node[key]); + if (inVal) { + return inVal; + } + } + } + } + return null; +} + +/** + * Aggregation EXPRESSION operators that execute server-side JavaScript. These + * are not stages, so the stage allow-list does not catch them — they live + * inside otherwise-allowed stages ($project / $group / $addFields / $match …). + * They must never run via DB Viewer (the find() path already strips the + * equivalent operators from filter/sort). + */ +const SERVER_SIDE_JS_OPERATORS = { + "$function": true, + "$accumulator": true, + "$where": true +}; + +/** + * Deep-scan an aggregation pipeline (objects and arrays, at every depth, + * including expression values) for a server-side-JavaScript operator. + * @param {*} node - pipeline / stage / expression node + * @returns {string|null} the operator name if present, else null + */ +function findServerSideJs(node) { + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + var inArr = findServerSideJs(node[i]); + if (inArr) { + return inArr; + } + } + return null; + } + if (node && typeof node === "object") { + for (var key in node) { + if (Object.prototype.hasOwnProperty.call(node, key)) { + if (SERVER_SIDE_JS_OPERATORS[key] === true) { + return key; + } + var inVal = findServerSideJs(node[key]); + if (inVal) { + return inVal; + } + } + } + } + return null; +} + +module.exports = { + whiteListedAggregationStages, + PROTECTED_JOIN_COLLECTIONS, + WRITE_STAGES, + SERVER_SIDE_JS_OPERATORS, + escapeNotAllowedAggregationStages, + findProtectedCollectionJoin, + findWriteStage, + findServerSideJs +}; diff --git a/plugins/dbviewer/api/parts/query_guard.js b/plugins/dbviewer/api/parts/query_guard.js new file mode 100644 index 00000000000..db0bbd85dd8 --- /dev/null +++ b/plugins/dbviewer/api/parts/query_guard.js @@ -0,0 +1,57 @@ +/** + * @module plugins/dbviewer/api/parts/query_guard + * @description Helpers that harden the user-supplied parts of a DB Viewer + * find() query (projection and the _id search term). + */ + +'use strict'; + +/** + * Restrict a find() projection to plain field inclusion / exclusion. + * + * A projection value is only allowed to be 0, 1 or a boolean (strict + * include/exclude). Any other value is dropped: + * - expressions and field-path aliases — e.g. { leak: "$password" } or + * { x: { $function: ... } } — would compute new fields from, or rename, + * fields the viewer otherwise removes from the response (MongoDB 4.4+ find() + * projections accept expressions); + * - other numbers (2, NaN, …) are not valid include/exclude values and can + * make the query throw. + * Keeping projections to strict include/exclude removes that whole avenue. + * + * @param {object} projection - parsed projection object (mutated in place) + * @returns {object} changes - keys are the projection fields that were dropped + */ +function sanitizeProjection(projection) { + var changes = {}; + if (!projection || typeof projection !== "object" || Array.isArray(projection)) { + return changes; + } + for (var key in projection) { + if (Object.prototype.hasOwnProperty.call(projection, key)) { + var value = projection[key]; + if (value !== 0 && value !== 1 && value !== true && value !== false) { + changes[key] = true; + delete projection[key]; + } + } + } + return changes; +} + +/** + * Escape a string for safe use as a literal inside a RegExp, so a user-supplied + * search term cannot introduce a pathological pattern (catastrophic + * backtracking / ReDoS). + * + * @param {string} str - raw search term + * @returns {string} the term with all RegExp metacharacters escaped + */ +function escapeRegExp(str) { + return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +module.exports = { + sanitizeProjection, + escapeRegExp +}; diff --git a/test/unit-tests/plugins.dbviewer.aggregation-guard.js b/test/unit-tests/plugins.dbviewer.aggregation-guard.js new file mode 100644 index 00000000000..bc26c850723 --- /dev/null +++ b/test/unit-tests/plugins.dbviewer.aggregation-guard.js @@ -0,0 +1,151 @@ +require("should"); +var guard = require("../../plugins/dbviewer/api/parts/aggregation_guard.js"); + +// The DB Viewer aggregation guard strips any non-whitelisted stage (e.g. +// $lookup / $unionWith / $graphLookup) so cross-collection reads cannot bypass +// the per-collection access check. The key requirement is that this holds at +// EVERY depth, including inside $facet sub-pipelines. +describe("dbviewer aggregation guard", function() { + describe("top level", function() { + it("keeps whitelisted stages", function() { + var pipeline = [{$match: {a: 1}}, {$group: {_id: "$x"}}, {$limit: 5}]; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + Object.keys(changes).length.should.equal(0); + pipeline.length.should.equal(3); + }); + it("strips a blocked $lookup at the top level", function() { + var pipeline = [{$lookup: {from: "members", as: "m"}}, {$limit: 5}]; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + changes.should.have.property("$lookup"); + // the now-empty $lookup stage is removed, $limit remains + pipeline.length.should.equal(1); + pipeline[0].should.have.property("$limit"); + }); + it("strips inherited Object.prototype keys (constructor / __proto__) as non-allow-listed", function() { + // a stage key like "constructor" resolves to a truthy inherited + // property on the allow-list object; the guard must still strip it + var pipeline = [JSON.parse('{"constructor": {"x": 1}}'), JSON.parse('{"__proto__": {"y": 1}}'), {$limit: 5}]; + guard.escapeNotAllowedAggregationStages(pipeline); + // only the legitimate $limit stage survives + pipeline.length.should.equal(1); + pipeline[0].should.have.property("$limit"); + }); + }); + + describe("nested inside $facet (the bypass)", function() { + it("strips a $lookup smuggled inside a $facet sub-pipeline", function() { + var pipeline = [{ + $facet: { + leak: [ + {$lookup: {from: "members", pipeline: [{$project: {email: 1, api_key: 1}}], as: "members"}} + ] + } + }]; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + changes.should.have.property("$lookup"); + // the $lookup must not survive anywhere in the pipeline + JSON.stringify(pipeline).indexOf("$lookup").should.equal(-1); + JSON.stringify(pipeline).indexOf("members").should.equal(-1); + }); + + it("strips blocked stages nested in deeper $facet within $facet", function() { + var pipeline = [{ + $facet: { + outer: [ + {$match: {a: 1}}, + {$facet: {inner: [{$unionWith: {coll: "members"}}]}} + ] + } + }]; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + changes.should.have.property("$unionWith"); + JSON.stringify(pipeline).indexOf("$unionWith").should.equal(-1); + }); + + it("keeps a $facet whose sub-pipeline only uses whitelisted stages", function() { + var pipeline = [{ + $facet: { + counts: [{$match: {a: 1}}, {$count: "n"}] + } + }]; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + Object.keys(changes).length.should.equal(0); + pipeline[0].should.have.property("$facet"); + pipeline[0].$facet.should.have.property("counts"); + pipeline[0].$facet.counts.length.should.equal(2); + }); + + it("drops a facet sub-pipeline emptied by sanitization (no empty $facet pipeline sent to mongo)", function() { + var pipeline = [{ + $facet: { + leak: [{$lookup: {from: "members", as: "m"}}] + } + }]; + guard.escapeNotAllowedAggregationStages(pipeline); + // leak became empty -> removed; $facet became empty -> stage removed + pipeline.length.should.equal(0); + }); + }); + + // Future-proofing: $facet is the only allow-listed pipeline-bearing stage + // today, but the sanitizer is structural — any kept stage exposing a + // `.pipeline` array also has it sanitized. Simulate a future allow-listed + // pipeline-bearing stage to prove blocked stages can't hide in its pipeline. + describe("generic nested-pipeline handling (future-proofing)", function() { + var FAKE = "$fakePipelineStage"; + beforeEach(function() { + guard.whiteListedAggregationStages[FAKE] = true; + }); + afterEach(function() { + delete guard.whiteListedAggregationStages[FAKE]; + }); + + it("strips a blocked stage nested in a kept stage's .pipeline", function() { + var pipeline = [{}]; + pipeline[0][FAKE] = {pipeline: [{$match: {a: 1}}, {$lookup: {from: "members", as: "m"}}]}; + var changes = guard.escapeNotAllowedAggregationStages(pipeline); + changes.should.have.property("$lookup"); + JSON.stringify(pipeline).indexOf("$lookup").should.equal(-1); + // the kept stage and its legitimate $match remain + pipeline[0].should.have.property(FAKE); + pipeline[0][FAKE].pipeline.length.should.equal(1); + pipeline[0][FAKE].pipeline[0].should.have.property("$match"); + }); + }); + + // Blocks joins into redacted collections (members / auth_tokens) at any + // depth, for everyone — including global admins, who skip the stage + // sanitizer but are still denied raw credentials via DB Viewer. + describe("findProtectedCollectionJoin", function() { + it("returns null for a pipeline with no joins", function() { + (guard.findProtectedCollectionJoin([{$match: {a: 1}}, {$group: {_id: "$x"}}]) === null).should.equal(true); + }); + it("ignores a join into a non-protected collection", function() { + (guard.findProtectedCollectionJoin([{$lookup: {from: "events", as: "e"}}]) === null).should.equal(true); + }); + it("detects a top-level $lookup into members", function() { + guard.findProtectedCollectionJoin([{$lookup: {from: "members", as: "m"}}]).should.equal("members"); + }); + it("detects a $lookup into members nested in $facet", function() { + guard.findProtectedCollectionJoin([{$facet: {leak: [{$lookup: {from: "members", as: "m"}}]}}]).should.equal("members"); + }); + it("detects a $lookup into members nested in another stage's .pipeline", function() { + guard.findProtectedCollectionJoin([{$lookup: {from: "events", pipeline: [{$lookup: {from: "members", as: "m"}}], as: "x"}}]).should.equal("members"); + }); + it("detects $unionWith (object form) into auth_tokens", function() { + guard.findProtectedCollectionJoin([{$unionWith: {coll: "auth_tokens", pipeline: []}}]).should.equal("auth_tokens"); + }); + it("detects $unionWith (string shorthand) into members", function() { + guard.findProtectedCollectionJoin([{$unionWith: "members"}]).should.equal("members"); + }); + it("detects $graphLookup into members", function() { + guard.findProtectedCollectionJoin([{$graphLookup: {from: "members", startWith: "$x", connectFromField: "a", connectToField: "b", as: "m"}}]).should.equal("members"); + }); + it("detects a join nested in an arbitrary (non-$facet, non-.pipeline) stage shape", function() { + // future-proofing: a join smuggled under some unknown stage shape + // that isn't $facet and doesn't use a .pipeline key must still be found + var pipeline = [{$someFutureStage: {branches: [[{$lookup: {from: "members", as: "m"}}]]}}]; + guard.findProtectedCollectionJoin(pipeline).should.equal("members"); + }); + }); +}); diff --git a/test/unit-tests/plugins.dbviewer.query-guard.js b/test/unit-tests/plugins.dbviewer.query-guard.js new file mode 100644 index 00000000000..c4c1400911c --- /dev/null +++ b/test/unit-tests/plugins.dbviewer.query-guard.js @@ -0,0 +1,94 @@ +require("should"); +var qguard = require("../../plugins/dbviewer/api/parts/query_guard.js"); +var aguard = require("../../plugins/dbviewer/api/parts/aggregation_guard.js"); + +describe("dbviewer query guard", function() { + describe("sanitizeProjection", function() { + it("keeps plain include/exclude projections", function() { + var p = {name: 1, _id: 0, "a.b": 1, ok: true, no: false}; + var changes = qguard.sanitizeProjection(p); + Object.keys(changes).length.should.equal(0); + p.should.have.property("name", 1); + p.should.have.property("a.b", 1); + }); + it("drops a field-path alias value (e.g. {leak: \"$password\"})", function() { + var p = {leak: "$password", k: "$api_key", name: 1}; + var changes = qguard.sanitizeProjection(p); + changes.should.have.property("leak"); + changes.should.have.property("k"); + p.should.not.have.property("leak"); + p.should.not.have.property("k"); + p.should.have.property("name", 1); + }); + it("drops an expression-object value (e.g. {x: {$function: ...}})", function() { + var p = {x: {$function: {body: "f", args: [], lang: "js"}}, y: {$concat: ["$password", ""]}, name: 1}; + var changes = qguard.sanitizeProjection(p); + changes.should.have.property("x"); + changes.should.have.property("y"); + p.should.not.have.property("x"); + p.should.not.have.property("y"); + p.should.have.property("name", 1); + }); + it("drops numeric values that are not strictly 0 or 1", function() { + var p = {a: 2, b: NaN, c: -1, ok: 1, off: 0, t: true, f: false}; + var changes = qguard.sanitizeProjection(p); + changes.should.have.property("a"); + changes.should.have.property("b"); + changes.should.have.property("c"); + p.should.not.have.property("a"); + p.should.not.have.property("b"); + p.should.not.have.property("c"); + p.should.have.property("ok", 1); + p.should.have.property("off", 0); + p.should.have.property("t", true); + p.should.have.property("f", false); + }); + }); + + describe("escapeRegExp", function() { + it("escapes regex metacharacters", function() { + qguard.escapeRegExp("(a+)+$").should.equal("\\(a\\+\\)\\+\\$"); + }); + it("leaves a plain id untouched", function() { + qguard.escapeRegExp("abc123").should.equal("abc123"); + }); + it("produces a literal-matching RegExp (no catastrophic pattern)", function() { + var re = new RegExp(qguard.escapeRegExp("(a+)+")); + re.test("(a+)+").should.equal(true); + re.test("aaaa").should.equal(false); + }); + }); + + describe("findWriteStage", function() { + it("returns null when no write stage is present", function() { + (aguard.findWriteStage([{$match: {a: 1}}, {$group: {_id: "$x"}}]) === null).should.equal(true); + }); + it("detects a top-level $out", function() { + aguard.findWriteStage([{$match: {a: 1}}, {$out: "stolen"}]).should.equal("$out"); + }); + it("detects a top-level $merge", function() { + aguard.findWriteStage([{$merge: {into: "members"}}]).should.equal("$merge"); + }); + it("detects a write stage nested in $facet", function() { + aguard.findWriteStage([{$facet: {w: [{$out: "x"}]}}]).should.equal("$out"); + }); + }); + + describe("findServerSideJs", function() { + it("returns null when no server-side-JS operator is present", function() { + (aguard.findServerSideJs([{$project: {x: 1}}, {$group: {_id: "$a", n: {$sum: 1}}}]) === null).should.equal(true); + }); + it("detects $function inside a $project expression", function() { + aguard.findServerSideJs([{$project: {x: {$function: {body: "f", args: [], lang: "js"}}}}]).should.equal("$function"); + }); + it("detects $accumulator inside a $group", function() { + aguard.findServerSideJs([{$group: {_id: null, v: {$accumulator: {init: "f", accumulate: "g", accumulateArgs: [], merge: "h", lang: "js"}}}}]).should.equal("$accumulator"); + }); + it("detects $where", function() { + aguard.findServerSideJs([{$match: {$where: "this.a==1"}}]).should.equal("$where"); + }); + it("detects a server-side-JS operator nested deep inside $facet", function() { + aguard.findServerSideJs([{$facet: {f: [{$addFields: {y: {$function: {body: "f", args: [], lang: "js"}}}}]}}]).should.equal("$function"); + }); + }); +});