From 994130ed718df044e27ffc9dba61b4e1d522d990 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:11:16 +0300 Subject: [PATCH] [fix][push] check audience filter operators on the estimate endpoint test, create and update all run the audience filter through validate(), which rejects unsafe Mongo operators in filter.user and filter.drill. estimate() builds the audience aggregation from the request directly, without validate(), so that check was skipped on this one endpoint. filter.user then reaches the $match stage of an aggregation over app_users unchecked, so a server-side-JS operator such as $function, including nested inside $expr, would execute in the database. Apply the same check estimate's siblings already use, before the aggregation runs. The check is the recursive operator walk, so it catches the operator at any depth. Legitimate audience filters, plain field predicates, are unaffected. --- plugins/push/api/api-message.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/push/api/api-message.js b/plugins/push/api/api-message.js index 301d863fcfb..4f2b2bf7499 100644 --- a/plugins/push/api/api-message.js +++ b/plugins/push/api/api-message.js @@ -612,6 +612,17 @@ module.exports.estimate = async params => { return true; } + // estimate() builds the audience aggregation from data.filter directly, without going + // through validate(), so apply the same operator check validate() runs for + // test/create/update. Without it, filter.user / filter.drill reach the $match stage of + // an aggregation over app_users unchecked, so a server-side-JS operator such as + // $function (including nested inside $expr) would execute in the database. + let badOp = common.findUnsafeMongoOperator(data.filter.user) || common.findUnsafeMongoOperator(data.filter.drill); + if (badOp) { + common.returnMessage(params, 400, common.unsafeQueryError(badOp)); + return true; + } + // Cross-app guard. validateRead only checks the caller's permission against // params.qstring.app_id, while the body's "app" field selects which app's // push audience is counted. Without this binding a user with push:read on