Query safety
Published
A document-store client takes its safety switches as options on the query, not as configuration on the connection. Filter sanitisation, the projection that keeps a password hash out of a response, the row cap and the validator switch are all property names passed per call, and every one of them defaults to the permissive setting. We protected a file that pins all of them, renamed the option names a group at a time, and read back which documents came out and what was in them.
What the sample actually does
The file runs three queries and two updates against a small collection of user documents. The documents carry a password hash and a multi-factor secret on the same records as the fields an API is meant to return, which is the ordinary situation and the reason a projection exists.
The first query is a sign-in lookup where the email value arrived from a JSON request body, carrying the classic operator-injection payload: an object that means "not equal to null" rather than a string. The second is an ordinary list for one tenant, sized so the row cap actually fires. The third is the same list query with an operator injected on the tenant column, which is how a row filter becomes a whole-collection read. The two updates are one legal edit and one that would set a role the schema does not define.
The client is copied in unprotected. Its defaults are the permissive ones this family of libraries documents: filter sanitisation off, no projection so every field is returned, no row cap, no server-side cancellation, and validators skipped on update.
Protection alone was applied first on five presets and all five behaved identically to the unprotected file, so nothing here is caused by protection by itself.
The injected filter started matching again
Renaming sanitizeFilter is the headline. With it in place the injected sign-in filter matched nothing, because the operator object is compared as data rather than executed as a query. With the option name renamed, the same filter matched users: the sample's login-matched-anyone assertion went from false to true, and the injected tenant query went from zero rows to two.
The switch reverting is not a bug in the client. Sanitisation defaults to off in this family of libraries for compatibility reasons that predate the attack becoming common, which is precisely why applications turn it on explicitly, and precisely why losing the name that turns it on is expensive.
What makes this quiet is that nothing else about the application changes. The sign-in endpoint still returns a normal response for a normal request. Ordinary queries with ordinary values behave identically, because sanitisation has nothing to do on them. Only a request carrying a structured value behaves differently, and the only party sending those is somebody probing you.
The projection stopped projecting, and the pair is worse than either half
Renaming select took the returned field list from five fields to seven. The two added fields are the password hash and the multi-factor secret. The sample's list-secret-leaked assertion went from false to true, and the rows in question are the ones an ordinary list endpoint serialises into a response body.
There is no error in that either. A query with no projection returns the whole document, which is the documented behaviour and often what you want in internal code. The projection is how an application says "not these two fields", and it says it with one property name.
Renaming both together produces the result worth remembering. The injected sign-in filter matches, and the matched row is returned with the password hash and the multi-factor secret in it. The bypass and the disclosure are separate options that a single member-renaming pattern scoped to "the query options object" would take out together, and neither one raises anything.
Rename all six options at once and the tenant list returns every row it can find, the injected tenant query returns four, the server-side cancellation goes from two seconds to none, and the update that should have been rejected is written.
The arm that fails closed on reads and open on writes
One arm in this area produced a result this series has not seen before, and it comes from renaming the field names of the documents themselves rather than the query options: the email, tenant, lock-state and role columns.
On the read side it fails closed, hard. The tenant list matched zero rows, because the filter now names columns the stored documents do not have. The returned field list came back empty. Anybody exercising the application notices immediately.
On the write side, in the same run, it opens. The update that tried to set a role of superadmin went from ok=false with validation-failed:role to ok=true and validated. The validator ran. It looked for the role field in the patch, did not find it under that name, concluded there was nothing to validate, and approved. The document then received a field named after a generated identifier carrying the value the validator would have rejected.
So the same rename makes reads visibly broken and writes silently permissive, and the visible half is the one that gets fixed first. A validator that reports success because it could not find the field it validates is the quietest form of this whole class, and it is why a validation suite should assert that a known-bad input is rejected, not merely that a known-good input is accepted.
The row cap, the timeout, and the control
Renaming limit took the tenant list from the two rows the application asked for to every row available. In this sample that is three; in production it is the size of the collection, which is the difference between a page and an export. Renaming maxTimeMS took the server-side cancellation from two seconds to none, so an expensive query has nothing to stop it.
One option was pinned to a value identical to the library default on purpose, and renaming it changed nothing, on both output targets. That is the seventh consecutive pass in which every option pinned that way behaved exactly as predicted. The useful part is the exclusion list it implies: the options that matter are the ones carrying a value the library would not have chosen, which is a property of your configuration and changes the day somebody edits a value.
Renaming the fields of the result the client hands back is the quiet direction again. The row count read undefined, and reading the written-keys map off the update result crashed with a type error - the loud half of a quiet direction, and the better outcome of the two.
What to do about it
The mechanism is the same one this series keeps measuring. Member renaming rewrites property names in the code it is given. A query options object is handed to an installed client that reads fixed names; a renamed name is absent, and the documented default applies. What makes query options unusually costly is that all of these defaults are the permissive ones and they are set per call rather than once on the connection, so there are many places to lose them.
Scope the renaming so that query options objects sit outside the MemberRegexp, or set the safety switches globally on the client rather than per query. A sanitisation switch set once at connection level is one name to protect instead of dozens, and it is better engineering regardless.
For verification, the cheapest assertion that catches the whole article is a negative one. Send the operator-injection payload at your sign-in endpoint in an integration test and require zero matches. Assert that a list response body does not contain your password-hash field name. Assert that a known-invalid update is rejected. Run all three against the protected artifact, because the source is not what runs.
Frequently asked questions
Does protecting my JavaScript cause NoSQL injection?
No. Protection alone was measured on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. The injection became possible only when member renaming removed the option name that turns filter sanitisation on.
Why is filter sanitisation off by default?
Compatibility. The clients in this family predate operator injection being a common attack, and turning sanitisation on by default would change the meaning of existing queries. That is exactly why applications enable it explicitly, and why losing the name that enables it is expensive.
What happened when the projection was lost?
The query returned the whole document instead of the five fields the application asked for, which put the password hash and the multi-factor secret into rows an ordinary list endpoint serialises. A query with no projection returning everything is documented behaviour, not an error.
How can a validator approve a value it should reject?
It looks for the field by name. If the field in the patch has been renamed, the validator finds nothing to validate and reports success, and the document is then written with a field named after a generated identifier carrying the rejected value. Assert that a known-bad input is refused, not just that a good one passes.
Is a row cap really a security control?
It is the difference between a page of results and an export of the collection. Losing it does not create an authorization failure, but it turns any endpoint that already returns rows into a bulk retrieval endpoint, and it removes the natural ceiling on an expensive query.
Which options in my own configuration are at risk?
The ones carrying a value the client would not have chosen for you. Options pinned to a value identical to the default measured completely inert across seven passes. That exclusion list belongs to your configuration, so it changes the day somebody edits a value away from the default.
What is the recommended fix?
Set the safety switches on the client once rather than per query, and scope member renaming so query options objects are excluded. Then add three negative assertions - injection returns nothing, a list response has no hash field, an invalid update is refused - against the protected build.
Related reading