| 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. |
|
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.
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:
Meteor.subscribe('team', { $where: 'while(true){}' }, 25, 0)
makes MongoDB evaluate an infinite loop per document scanned, pinning a worker
thread for as long as the caller wants. It can be sent again immediately. That is
denial of service for the whole instance — every tenant, every board —
from one account.
{ $where: 'sleep(2000) || true' } made the
people subscription become ready in 2.03s and return the document;
the same subscription with { $where: 'false' } became ready in 0.00s
and the same, otherwise-always-matching document was not returned. The
timing rules out coincidence and the pair proves the string is evaluated as
JavaScript with the caller controlling which documents come back — a
document-inclusion oracle.
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.
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.
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.