Skip to content

refactor(dbviewer): validate aggregation operators against an allow-list (release.24.05) - #7869

Open
ar2rsawseen wants to merge 4 commits into
release.24.05from
backport/dbviewer-aggregation-operator-allowlist-2405
Open

refactor(dbviewer): validate aggregation operators against an allow-list (release.24.05)#7869
ar2rsawseen wants to merge 4 commits into
release.24.05from
backport/dbviewer-aggregation-operator-allowlist-2405

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Jul 30, 2026

Copy link
Copy Markdown
Member

Backport of #7868 to release.24.05.

Same change: the aggregation guard no longer guesses which nested arrays are sub-pipelines and no longer strips anything. It walks every object and array to any depth, checks only keys beginning with $ against a per-role allow-list, and rejects the request naming the operator and its path. find() projections reject rather than having offending fields dropped.

Also carries the password_reset.prid fix: the reset route looks that value up directly as password_reset.findOne({prid}), so it is withheld on all three read paths and refused as a join target for every role. The per-site copies of the redaction field list moved into parts/redaction.js as one table.

Full reasoning in #7868.

Differences from the master PR

Not a clean cherry-pick, api.js needed three adjustments:

  • The projection rejection sits in a function that returns a boolean on this branch, so it is return false rather than return.
  • aggregate() takes two arguments here, with no changes parameter, so there was nothing to keep passing.
  • This branch guards the aggregation path with a plain if (hasAccess). Master has extra collection-name alternatives in that condition. Kept this branch's form; it is the tighter of the two.

Tests

56 cases across plugins.dbviewer.aggregation-guard.js, plugins.dbviewer.query-guard.js and the new plugins.dbviewer.redaction.js, all passing on this branch.

Full unit suite here: 116 passing, 5 failing. All five are pre-existing and unrelated: two Countly Request tests make real HTTP calls, and three validateArgs tests fail on mongodb.ObjectID is not a function, a driver rename. None touch dbviewer.

🤖 Generated with Claude Code

The guard used to decide which nested arrays were sub-pipelines by looking
for a recognised stage name inside them, then strip whatever it disallowed.
That made correctness depend on knowing every stage name MongoDB has,
including undocumented internal ones, and an unrecognised name made a whole
branch invisible to the filter.

Replaced with a blind traversal that visits every object and array at any
depth and checks only keys beginning with "$" against a per-role allow-list,
matched exactly. Nothing is inferred about structure, so there is no shape
to get wrong. Values are never inspected, which is what makes keys-only
checking sound: MongoDB reaches a "$"-prefixed field name through $getField
or $setField, where the name is a value, never a key.

The pipeline is no longer modified. A disallowed operator rejects the request
with its name and path instead of being deleted and the query run anyway, so
a caller is told rather than silently given different results. find()
projections behave the same way now.

Operator lists were verified against a running MongoDB rather than the docs;
verify_operators.js is that probe, kept so the lists can be rechecked on an
upgrade. It found "$sharedDataDistribution" in the old list, which MongoDB
spells "$shardedDataDistribution".

Existing protections are unchanged: server-side JavaScript, write stages and
joins into redacted collections are refused for every role at any depth.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 30, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

ar2rsawseen and others added 3 commits July 30, 2026 16:53
…ipping

The suite still called sanitizeProjection, which findDisallowedProjectionValue
replaced, so it failed to load. Rewritten for the new contract and extended to
assert the projection is left untouched when rejected.

Co-Authored-By: Claude <noreply@anthropic.com>
password_reset.prid is the password-reset token: the reset route looks it up
directly as password_reset.findOne({prid}), so the value is the reset link.
The viewer returned it in full.

Withheld on all three read paths and refused as a join target for every role,
matching how members credentials are already handled.

The three redaction sites (single-document read, collection read, aggregation)
each carried their own copy of the field list, which is how a collection gets
covered in two of the three. Moved to parts/redaction.js as one table, so
adding a collection covers all three, and the module is unit-testable like the
other guards. A test asserts the aggregation and document paths cover the same
fields, so they cannot drift apart.

auth_tokens stays at the call sites: its secret is the _id, which cannot be
dropped without breaking the row, so the value is replaced instead.

Co-Authored-By: Claude <noreply@anthropic.com>
The script prints through mongosh's print(), never console, so disabling
no-console was pointless and warns as an unused directive under the
countly-platform eslint config.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants