Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / WhereBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
GHSA-phm4-4v26-j2vq

WhereBleed

2026-08-11 TungNGo02 and xet7

Coordinated disclosure via GitHub Security Advisory GHSA-phm4-4v26-j2vq.
  • WhereBleed — eight Admin Panel handlers took a MongoDB query selector from the client and validated only its TYPE, which is not validation: a selector is executable data, and $where makes the database run the caller's JavaScript (CWE-943, CVSS 5.8)
  • A blocking payload such as { $where: 'while(true){}' } pins a database worker for as long as the caller likes and is repeatable — denial of service for every tenant on the instance
  • Affected the people, org, team and translation publications and their *CollectionCount / getPeoplePageIds methods; the card-window publication already had the guard the eight were missing
  • Fixed at upcoming WeKan release


Details

WhereBleed — a type check is not a validation (CWE-943)

The Admin Panel's People, Org, Team and Translation panes each let an administrator search and page through a table. The client sends the query it wants run, and the publication behind it checked that query like this:

// server/publications/people.js — before
Meteor.publish('people', async function(query, limit, skip = 0) {
  check(query, Match.OneOf(Object, null));   // the TYPE, and nothing else
  ...
  ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, query), { ... });
});
    

check(query, Match.OneOf(Object, null)) asks whether the thing is an object. It is. That is the whole of the validation, and it is not enough, because a MongoDB selector is not data that gets compared — it is data that gets executed. The $where operator takes a string of JavaScript and the database runs it, once for every document it scans.

What that buys the caller

Two things, and the reporter demonstrated both against ghcr.io/wekan/wekan:v10.81 on a real MongoDB 7 backend (docker-compose-mongodb-v7.yml), over an ordinary DDP connection:

Why it is worth fixing even though it needs an admin

Reaching any of this requires an authenticated session that can open the Admin Panel, so no ordinary board member and no unauthenticated visitor can touch it. What raises it above "an administrator can inconvenience their own site" is multitenancy: the People and Org surfaces are open to a per-tenant Global Admin, a role that is supposed to be confined to one Organization. Those two publications scope the caller by merging the restriction with the query:

// models/lib/tenantAdmin.js
function peopleScopeSelector(user, query) {
  if (isSiteAdmin(user)) return isPlainObject(query) ? { ...query } : {};
  ...
  return andQuery(query, { 'orgs.orgId': { $in: ids } });   // $and — nothing is removed
}
    

Merging under $and correctly stops a crafted query from arguing its way out of the tenant restriction — but it never removes anything from the query either, so the $where travels straight through it. A role scoped to one Organization could therefore affect the entire instance, which is more than that role is meant to be able to do.

The part that stings: the defence was already here

WeKan already had a purpose-built check for exactly this class of bug — classifySelector in models/lib/injectionDetect.js and hasWhere in models/lib/mongoSelectorSafety.js, both with unit tests, both wired into the windowed card publication (server/publications/cardsWindow.js) through a small local helper. Eight sibling handlers accepting the identical shape of client-supplied selector were never wired into it. The protection existed, was trusted, and was applied to one of nine places.

So the fix adds no new detection logic at all. That publication's helper moved, unchanged, into server/lib/selectorGuard.js, and all nine call sites now share the one copy — leaving a second copy behind would have been the same mistake set up to happen again:

// server/publications/people.js — after
import { safeSelector } from '/server/lib/selectorGuard';
...
const safeQuery = safeSelector(query, 'people');
ret = await ReactiveCache.getUsers(tenantAdmin.peopleScopeSelector(user, safeQuery), { ... });
    

A refused selector becomes { _id: { $in: [] } } — valid, cheap, and matching nothing — which is the refusal the card window already used in production. An administrator who sends something dangerous gets an empty table rather than an exception mid-page, and the attempt is recorded with who sent it and from where. Nothing an Admin Panel pane really sends is affected: none of its searches, filters, regexes, $or/$and/$in/$elemMatch or date ranges contains an execution operator.

Which databases this reached

WeKan's default database is FerretDB, which rejects $where itself, so on a default install the attack degrades to a rejected query. WeKan also ships and documents a real-MongoDB deployment, and that is where the impact above was reproduced. MongoDB 7 separately refuses $where inside the aggregation pipeline the four count methods use — but that is an engine-level restriction on one operator in one call path, not something the application does, and $function and $accumulator are legitimate aggregation operators there. The count methods are guarded like everything else rather than left relying on it.

Reported by TungNGo02 via GitHub Security Advisory GHSA-phm4-4v26-j2vq, confirmed against v10.81, with a recommended patch included in the report. Fixed at the upcoming WeKan release.