Search and data access
Published
A search box is the one place in most applications where a broad query runs against everything by design, and the only thing keeping one customer's records out of another customer's results is a clause in an options object. Which documents are in scope, which fields are matched, which fields come back, how many rows, and an authorization test only your application can express - all property names, all read by a client library that was installed rather than built. We protected a file that builds such a query, renamed the names a group at a time, and looked at whose documents came back.
What the sample actually does
The file builds a search the way a careful team builds one in a multi-tenant application. A filter restricts the query to the caller's own tenant. The matched fields are limited to the title and the body, so staff-only commentary is not searchable. The returned fields are restricted by an explicit list. A ceiling bounds the page size. And the application supplies an authorization callback that compares each candidate document's tenant against the caller's - the same restriction as the filter, expressed a second time, deliberately.
That duplication is not an accident in the fixture; it is what defence in depth looks like in a real query builder, and measuring it turned out to be the most useful thing in the area.
The index contains documents from two tenants, and one of the caller's own documents carries a salary and a staff-only note, so the field restrictions can be measured on a document the filter and the authorizer both allow. Without that, the tenant guard refuses first and the projection options are never reached.
The search client is copied in unprotected. Its defaults are the permissive ones: no filter, every field matched, the whole document returned, a thousand rows, and an authorization hook that asks only whether the caller is signed in at all. Protection alone was applied first on all five presets, and all five behaved identically to the unprotected file.
Two harmless-looking arms that were the same finding
Renaming the tenant filter alone changed nothing about which documents came back. The hit count was identical and no foreign document appeared, because the authorization callback caught every one of them. Renaming the authorization callback alone changed nothing either - the reported authorizer moved from caller-supplied to the library's signed-in-only builtin, and the results stayed inside the caller's tenant, because the filter had already excluded everything else.
Two arms, each producing a diff in a reported value and no change in behaviour. Both are exactly the finding a reviewer closes as cosmetic.
Renaming both together - which is what one pattern over an options object does - produced cross-tenant-results=true and returned another customer's documents to a signed-in user of the first. The defence in depth was real and it worked, right up to the point where one edit removed both layers at once, which is the specific thing defence in depth is not designed for. Two independent controls stop being independent when a single mechanical transformation can reach both.
This is the ninth consecutive pass in this series in which a guard was safe on each half and open on both, and it is the cleanest example so far, because here the two halves are not two parts of one rule - they are two separate rules that happen to enforce the same property.
The salary that went to the browser
The returned-field restriction is an explicit list, and renaming it reverted the query to returning the whole document. The measured line moved from four fields to six, and the JSON delivered to the browser gained a salary and a staff-only note reading counteroffer approved at 185k.
The user interface, of course, renders neither. It draws the title and the snippet it always drew, so the page looks correct and nobody reviewing the screen would see anything. The data is in the response body, which is one developer-tools tab away and, more importantly, in every proxy log and browser cache between your server and the reader.
The same restriction written as an exclusion list rather than an inclusion list failed the same way and for the same reason - both are lists of strings naming properties, and reverting either yields the whole document. But the two differ under a different edit. Renaming the document's field names left the exclusion list pointing at strings that no longer name anything, so the fields it was supposed to strip came back under their new names; the inclusion list, under the same edit, returned nothing at all. An allowlist fails closed when a name moves. A denylist fails open. That is the same conclusion this series reached in a log redactor and a browser store, now measured a third time in a query builder.
Matching, separately, reverted from title-and-body to every field, and a search for a phrase that appears only in staff commentary began returning hits. The searcher never sees the note - but the existence of a hit for the exact words tells them what it says, which is the whole content of the leak.
The rest of the arms
The page-size ceiling reverted from five to the library's thousand, and a query that should have returned five rows returned every match. A search endpoint with no ceiling is an export endpoint: one crafted query walks the index at whatever rate the client can ask.
The document's own field names, renamed as a group, produced a mixed result worth separating. The filter clause still reads tenant as a literal string, so the filter silently stopped matching the property it names - a rule written as data does not move when the property it refers to moves. The tenant field also vanished from the results, so the check that reports which tenants appeared in the output lost its ability to report anything: tenants-in-results=(no tenant field returned). The detector and the thing it detects went in one edit.
Renaming the ordinary content fields - id, title, body - failed closed instead: nothing matched, nothing scored, and the search returned zero results for every query. Loud, immediate, and fixed the same day.
Two arms measured no change. The analyzer is a control pinned to the library default. The minimum score turned out to be a second control by accident - it was pinned to zero, which is also the library's default, so renaming it moved which name carries a value the library would have used anyway. That is worth stating plainly rather than counting as a safe arm: an option whose configured value equals the default cannot demonstrate anything when renamed, and the useful audit question is which options carry a non-default value.
What to do about it
The mechanism is not specific to search and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given, and an installed search client is not inside that code.
Scope RenameMembers with a MemberRegexp that excludes the query object and the document shape together. They belong in the same exclusion because a filter clause is a string naming a document property; excluding one without the other lets them drift apart, which is the arm where the tenant filter stopped matching anything.
Do not rely on two layers of the same check for this class of risk. The tenant filter and the authorization callback are genuinely independent controls in every other respect, and a single rename pattern reached both, because they are properties of the same object in the same file. If you want a second layer that survives, put it somewhere the transformation cannot reach - the query the server actually executes should be re-scoped server-side rather than trusted from the client.
Then verify by result rather than by configuration. Run a search as a user of one tenant against an index seeded with a canary document belonging to another, and assert it does not come back. Run a search that matches a document with a confidential field and assert that field is absent from the response body, not merely absent from the screen. Both checks are indifferent to what anything is called, and between them they catch every arm in this article.
Frequently asked questions
Does protecting my JavaScript break search on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.
What was the worst result?
Losing the tenant filter and the authorization callback together, which returned another customer's documents to a signed-in user. Each of those two was individually harmless because the other caught what it missed, so both arms looked cosmetic in isolation.
Doesn't defence in depth protect against exactly this?
Only against independent failures. The two controls here are independent in every respect except one: they are properties of the same object in the same file, so a single rename pattern reaches both. If you want a layer that survives, put it somewhere the transformation cannot reach - re-scope the query server-side.
How did a salary reach the browser if the interface never shows it?
The field restriction reverted to returning the whole document. The interface drew what it always draws, so the page looked correct, but the salary was in the response body - visible in developer tools and present in any proxy log or cache between the server and the reader.
Is an inclusion list better than an exclusion list here?
Under one edit they behave identically; under another they diverge sharply. When the document's own field names move, an exclusion list points at strings that name nothing and the confidential fields come back, while an inclusion list returns nothing at all. An allowlist fails closed, a denylist fails open.
Two options seemed completely unaffected. Were they safe?
They were controls, not evidence. Both were pinned to values identical to the library's defaults - one deliberately, one by coincidence. Renaming an option whose value already equals the default cannot change anything. The options worth auditing are the ones carrying a non-default value.
What is the smallest change that prevents all of this?
Scope RenameMembers to exclude the query object and the document shape together, then add two result-level checks: a search from one tenant must not return another tenant's canary document, and a search matching a document with a confidential field must not carry that field in the response body.
Related reading