[fix][core] validate scalar request parameter types before using them in queries - #7937
Open
ar2rsawseen wants to merge 1 commit into
Open
[fix][core] validate scalar request parameter types before using them in queries#7937ar2rsawseen wants to merge 1 commit into
ar2rsawseen wants to merge 1 commit into
Conversation
… in queries
params.qstring values are not guaranteed to be strings. api/api.js fills
params.qstring straight from formidable's fields, and with formidable 2.1.3 a
POST body sent as application/json puts nested objects there. So a body like
{"view": {"$ne": null}} leaves params.qstring.view an object rather than the
string the endpoint expects. Form-urlencoded bracket syntax does not do this,
it keeps a literal string key, so JSON bodies are the case that matters.
Several endpoints then use such a parameter as a plain value inside a Mongo
match document. In a value position Mongo reads an object as a query
expression, so an equality match on one document becomes a match on many. Two
consequences: the query returns rows the endpoint never meant to return, and it
loses the bound on how much it has to scan. The heatmap case is the expensive
one: getHeatmap builds one $match from view, actionType and segment, then, when
use_union_with is on, $unionWith's the older drill collection into the same
pipeline over a caller-chosen period. Widening that match turns a single cheap
request into a very large aggregation on the drill database.
Adds common.isQueryScalar and applies it where a scalar parameter reaches a
query with no type check:
plugins/views/api/api.js getHeatmap: view, actionType, segment
plugins/star-rating/api/api.js /o/feedback/data: widget_id, version,
platform, uid
/o/feedback/widgets: is_active
plugins/crashes/api/api.js method=user_crashes: uid
api/parts/mgmt/users.js fetchNotes: note_type
api/utils/requestProcessor.js /i/token/delete: tokenid
plugins/systemlogs/api/api.js member lookup: api_key
Strings and numbers pass through unchanged, so legitimate callers are
unaffected. /o/actions in particular still takes view as a string URL,
actionType as "click" or "scroll" and segment as a string; only non-scalars are
refused, with 400 and the parameter name, the same way device and period were
already checked there. null and undefined stay scalars so the existing
truthiness checks at each call site keep deciding whether an absent parameter
belongs in the query at all.
common.parseUserQuery and common.findUnsafeMongoOperator were not the right
tool here: they validate parameters that are queries in their own right and
only reject the JS-executing operators, so they accept $ne and $regex by
design. Endpoints that legitimately take a whole user query (cms, dbviewer,
/o/tasks) already go through them and are left alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
params.qstringvalues are not guaranteed to be strings.api/api.jsfillsparams.qstringstraight from formidable'sfields, and with the formidable 2.1.3 this repo ships, a POST body sent asapplication/jsonputs nested objects there. A body like{"view": {"$ne": null}}leavesparams.qstring.viewan object rather than the string the endpoint expects. Form-urlencoded bracket syntax (view[$ne]=x) does not do this, it keeps a literal string key, so JSON bodies are the case that matters.Several endpoints then use such a parameter as a plain value inside a Mongo match document. In a value position Mongo reads an object as a query expression, so an equality match on one document becomes a match on many. Two consequences:
The heatmap case is the expensive one.
getHeatmapbuilds one$matchfromview,actionTypeandsegment, and whenuse_union_withis on it$unionWiths the older drill collection into the same pipeline over a caller-chosenperiod. Widening that match turns a single cheap request into a very large aggregation on the drill database. That is the main motivation for this change.How
Adds
common.isQueryScalar(value): true for strings, numbers, booleans,nullandundefined, false for objects and arrays.null/undefinedstay scalars on purpose, so the existing truthiness checks at each call site keep deciding whether an absent parameter belongs in the query at all.common.parseUserQueryandcommon.findUnsafeMongoOperatorwere considered first and are not the right tool here. They validate parameters that are queries in their own right, and they only reject the JS-executing operators, so they accept$neand$regexby design. Endpoints that legitimately take a whole user query already go through them and are untouched.Compatibility
Strings and numbers pass through unchanged, so legitimate callers see no difference.
/o/actionsstill takesviewas a string URL,actionTypeas"click"or"scroll"andsegmentas a string. Only non-scalars are refused, with a 400 naming the parameter, the same waydeviceandperiodwere already checked in the same function.Sites surveyed
The whole class was swept across
api/and every pluginapi/directory, resolving one level of local-variable indirection rather than relying on same-line matches. Verdict per site:Fixed
plugins/views/api/api.jsgetHeatmapview,actionType,segmentplugins/star-rating/api/api.js/o/feedback/datawidget_id,version,platform,uidplugins/star-rating/api/api.js/o/feedback/widgetsis_activeplugins/crashes/api/api.jsmethod=user_crashesuidapi/parts/mgmt/users.jsfetchNotesnote_typeapi/utils/requestProcessor.js/i/token/deletetokenidplugins/systemlogs/api/api.jsmember lookupapi_keyAlready validated, left alone
api/utils/rights.js(7 sites): each doesparams.qstring.api_key = params.qstring.api_key + ""immediately before building the query.app_idoruser_id:processRequestrejects a non-24-length value up front, which covers objects.app_key,device_id,old_device_id:processRequestcasts these to strings for every request.plugins/crashes/api/api.jsmethod=crashes(group): cast with+ ""on the line above.api/parts/data/fetch.jsjobDetails(name): cast with+ ""on the line above.plugins/populator/api/api.js(name,template_id):namegoes throughcommon.validateArgswithtype: "String"first;template_idgoes throughcommon.db.ObjectIDin a try/catch first.plugins/alerts/api/api.js,plugins/star-rating/api/api.jswidget delete,api/parts/mgmt/users.jsnote edit/delete,api/parts/mgmt/apps.jsapp plugins,plugins/compare/api/api.js: the value reachescommon.db.ObjectID(or a length check) before any query.api/parts/mgmt/cms.js(lines 202 and 289),/o/tasksand the app_users query path: these take a user-supplied query by design and already validate it withcommon.parseUserQuery.plugins/dbviewer/api/api.jsvalidates itsfilterandsortwithcommon.findUnsafeMongoOperator(line 235).plugins/dbviewer/api/api.jsline 133 (document) is a genuine instance of the pattern and was still left alone: the same endpoint accepts an arbitraryfilterandsortby design, and a non-global-admin document lookup is additionally constrained bygetBaseAppFilter, so constrainingdocumentwould grant nothing the endpoint does not already grant. Worth revisiting if the dbviewer query surface is ever narrowed.False positives
segment/segmentValin the/o/viewsbranch (hashed into a collection name and into$d.…field paths),metricinfetch.metricToCollection, the populator$regexprefix built with"^" + app_id + "_" + template_id.$setpayloads rather than query filters:homeSettings,sdk.name/sdk.version/did/t/tzincommon.js,consent.*in compliance-hub,hcin sdk,event_map/omitted_segmentsin requestProcessor. Those write request data into a document, which is what they are for, and are not operator positions.sEcho,iTotalRecordsand friends: response fields, not query fields.$in,$regexand$text: {$search: …}positions (event_groupsids, viewssSearch, star-ratingsSearch, fetch.jsevents): Mongo rejects a non-array$inand a non-string$regex/$search, so these error out rather than widening. Left as they are to keep this change to the one pattern.sort/projectionkeys are all looked up in fixed column arrays, so no request value becomes a key.Tests
test/unit-tests/api.utils.common.js: unit tests forcommon.isQueryScalar. These run intest-api-coreand pass there.plugins/views/tests/heatmaps.js: a new case seeds a second action on a different view, then asserts that a stringviewstill returns only that view's action and that an objectviewis refused with 400 instead of returning both. A second case covers non-scalaractionTypeandsegment, and confirms a stringsegmentis still accepted.Worth knowing before reviewing: this file does not currently run in CI.
plugins/views/tests/index.jshas//require('./heatmaps.js');, disabled in 01703b8 (2025-04-29). As written the suite cannot pass in this repo's CI anyway: it readsresp.body.plugins.drillfrom/o/apps/pluginsand connects tocountly_drill, neither of which exists here. The new cases were left in that file because it is where the other/o/actionscases live, so they run wherever that suite is run against a drill-enabled instance. Re-enabling the suite is a separate decision and is not part of this PR.Verification
node --checkon all 9 changed files: clean.eslint -c .eslintrc.jsonon all 9 changed files: 0 errors (2 pre-existingPromise.eachwarnings inplugins/views/api/api.js, on lines this PR does not touch).mocha test/unit-tests/api.utils.common.js: 41 passing, including the 4 new ones.typeof fields.view === "object".plugins/views/tests/heatmaps.jsneed a drill-enabled instance running this branch and were not executed.test-api-pluginsfailed once onplugins/web/tests.js"should have Chrome in browser metrics", a UA and client-hints parsing assertion, and passed on re-run with no change to the branch. Nothing in this PR touches UA parsing, the browser metric, or any write path, and the same job passed first time on the 24.05 backport of this change ([fix][core] validate scalar request parameter types before using them in queries (24.05) #7938) and on other current PRs. All checks are green.