drizzle-team / drizzle-team/drizzle-orm
hashQuery cache key collision: undefined array element serialises identically to null
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
**Severity:** LOW — correctness edge case; collision exists when a caller explicitly passes `undefined` as an array element
---
## Description
`hashQuery` in `drizzle-orm/src/cache/core/cache.ts` builds a cache key via `JSON.stringify(params)`. `JSON.stringify` silently converts every `undefined` element inside an array to `null` (per ECMAScript spec). As a result, `[null]` and `[undefined]` serialise to the identical string `[null]`, producing the same SHA-256 hash and the same cache key.
This means two structurally different parameter arrays can collide on the same cache slot. If a query is cached under `params = [null]` and a subsequent call arrives with `params = [undefined]` (for example, from a code path where an optional argument is not validated before being forwarded to the ORM), the cache layer will consider the keys identical. The practical impact depends on whether a caller can reach `hashQuery` with an `undefined` array element; TypeScript's type system discourages this, but JavaScript callers or type-cast code paths are not protected.
The collision itself is a demonstrable correctness gap in the key-derivation function regardless of how commonly the triggering path is exercised.
## Affected code
```ts
69 | export async function hashQuery(sql: string, params?: any[]) {
70 | const dataToHash = `${sql}-${JSON.stringify(params)}`; // ← undefined→null coercion
71 | const encoder = new TextEncoder();
72 | const data = encoder.encode(dataToHash);
73 | const hashBuffer = await crypto.subtle.digest('SHA-256', data);
74 | const hashArray = [...new Uint8Array(hashBuffer)];
75 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
76 | return hashHex;
77 | }
```
## Fix recommendation
```ts
function stableSerialize(params: any[] | undefined): string {
if (params === undefined) return 'undefined';
return JSON.stringify(params, (_key, value) =>
value === undefined ? '__undefined__' : value
);
}
export async function hashQuery(sql: string, params?: any[]) {
const dataToHash = `${sql}-${stableSerialize(params)}`;
// ... rest unchanged
}
```
### PoC code
```js
async function hashQuery(sql, params) {
const dataToHash = `${sql}-${JSON.stringify(params)}`;
const encoder = new TextEncoder();
const data = encoder.encode(dataToHash);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = [...new Uint8Array(hashBuffer)];
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
const sql = 'SELECT * FROM users WHERE id = ?';
const hashWithNull = await hashQuery(sql, [null]);
const hashWithUndefined = await hashQuery(sql, [undefined]);
console.log('JSON.stringify([null]) :', JSON.stringify([null]));
console.log('JSON.stringify([undefined]):', JSON.stringify([undefined]));
console.log('hash([null]) :', hashWithNull);
console.log('hash([undefined]):', hashWithUndefined);
console.log('Collision:', hashWithNull === hashWithUndefined);
if (hashWithNull === hashWithUndefined) { console.log('BUG_CONFIRMED'); }
```
### PoC verbatim output
```
=== Serialised strings ===
params=[null] : SELECT * FROM users WHERE id = ?-[null]
params=[undefined]: SELECT * FROM users WHERE id = ?-[null]
params=undefined : SELECT * FROM users WHERE id = ?-undefined
=== Hashes ===
hash([null]) : 48bdd4b0848cf8f1eebb8b93d0a1fcc2dfd147f738ee2c6488af3c247b7b5a87
hash([undefined]): 48bdd4b0848cf8f1eebb8b93d0a1fcc2dfd147f738ee2c6488af3c247b7b5a87
hash(undefined) : 3567290e9057bfc51649472a292cc444973e7d82656a53f94938823b152cbce2
[COLLISION] params=[null] and params=[undefined] produce the same cache key!
[SCENARIO] A cached result for params=[null] is incorrectly returned
for a call with params=[undefined], silently serving stale/wrong data.
BUG_CONFIRMED
```
Contributor guide
Assessment
This issue has not been assessed yet.