drizzle-team / drizzle-team/drizzle-orm

hashQuery cache key collision: undefined array element serialises identically to null

Open
#5,842 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.