Build1 distinct publisher3 min readUpdated
Two palette functions in a fifteen-year-old npm dependency exhaust the heap on a negative or fractional count. A differential fuzzer with 31 million comparisons could not see it.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
A developer porting TinyColor to Rust has reported that two of the library's palette functions, `analogous()` and `monochromatic()`, do not terminate when handed a negative or fractional results count, and take the JavaScript heap with them [1]. The library is fifteen years old, ships on npm as tinycolor2, and sits underneath a lot of design tooling [2], so the realistic path to this is mundane: a number field in a colour picker, or a value read out of a config file [3].
Both functions drive the loop off the caller's count and test it for truthiness rather than for range. `analogous` uses `for (hsl.h = ...; --results; )` and `monochromatic` uses `while (results--)` [4]. A counter that never lands exactly on zero never stops [5]. From 1.5 the sequence runs 0.5, -0.5, -1.5, -2.5; from -1 it runs -2, -3, -4 [6]. Every pass pushes another colour object onto an array nobody will ever read [7]. With old space capped at 256 MB, `require('tinycolor2')('red').analogous(-1)` dies with a fatal heap-limit error and exit code 134 [8]. According to the writeup, six cases behave this way: both functions at -1, 1.5 and 0.5 [9], which is two functions across three values [10].
The testing lesson is in how it was found. The author had built a differential fuzzer that ran the original on V8 against the Rust port and compared outputs bit for bit, 31 million comparisons with zero disagreements in the colour maths [11]. It found nothing here, because for these inputs the original does not return a wrong answer; it does not return at all, and you cannot diff against a process that has died [12]. Any harness whose only failure signal is "the two outputs differ" is structurally blind to non-termination. The bug class worth auditing for is not incorrect results but loop conditions fed by untrusted arithmetic: `while (n--)` and `--n` where n arrives from a caller.
The reason this survived fifteen years is the first line of the function: `results = results || 6` [13]. That is a default, not a guard, and it happens to swap out every falsy value before the loop sees it, so `analogous(0)`, `analogous(null)` and `analogous(NaN)` all return six colours [14]. The author says three clean probes nearly made him dismiss his own report, and he only came back to it an hour later [15]. The function is safe for the values a developer would casually try and unsafe for the ones they would not, which is close to the worst possible shape for a defect.
The hazard is already recognised inside the same file. `polyad()` throws "Argument to polyad must be a positive number" when its argument is NaN or less than or equal to zero [16]. The proposed patch copies that check into both functions and adds tests mirroring polyad's existing ones [17]. Also worth noting what the author verified rather than assumed: `analogous`'s second argument, `slices`, is defaulted the same lazy way with `slices || 30` [18], but it only feeds `part = 360 / slices`, so bad values produce a wrong hue or a NaN channel instead of a hang, and -1, 1.5 and NaN all return six colours and terminate [19].
What to watch: issue bgrins/TinyColor#280 and the accompanying pull request [20], filed as part of DEV's Summer Bug Smash powered by Sentry [21]. Until a published release carries the guard, validation belongs to the caller: reject non-positive and non-integer palette sizes before they cross into the library, and treat the same pattern in your own code as a liveness bug rather than a correctness one.
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
Any caller passing a user-supplied palette size into analogous() or monochromatic() has an unauthenticated way to kill the process: a number field in a colour picker, or a value out of a config file, with no exotic input needed.
Running node --max-old-space-size=256 -e "require('tinycolor2')('red').analogous(-1)" produces FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, and exit status 134.
Six cases all exit 134: both functions at -1, 1.5 and 0.5.
The report was filed as issue bgrins/TinyColor#280 with an accompanying pull request.
TinyColor's analogous(results) and monochromatic(results) loop forever on a negative or fractional count and take the heap with them; the report came from a developer porting the library to Rust.
TinyColor is a JavaScript colour library, fifteen years old, with 5.2k stars, shipping on npm as tinycolor2 underneath a lot of design tooling.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Reproducible and specific, but single-source and self-reported
The technical core is unusually checkable for a single-source story: quoted loop headers, the exact counter sequences, a one-line node invocation with the V8 fatal error and exit code 134, six enumerated failing cases, a re-check against current main, and a negative control showing the slices argument terminates. What is missing is independence — one publisher, the discoverer as sole witness, no maintainer confirmation, no advisory identifier and no third-party reproduction.
Widely embedded library, essentially zero uptake of the fix
On the exposure side the only disclosed signals are qualitative-to-thin: fifteen years old, 5.2k stars, published as tinycolor2, said to sit under 'a lot of design tooling', with no download or dependent counts. On the remediation side adoption is effectively nil: the PR is open, upstream has merged nothing since February 2023, and the only shipped change is a deliberate divergence in the author's own Rust port. No downstream consumer is reported to have validated inputs or pinned in response.
Slight overstatement in severity framing, otherwise disciplined
The writeup is notably self-limiting: it corrects its own assumption about slices, searches a hundred issues for duplicates, and explains why the fuzzer's 31 million comparisons prove nothing here. The mild stretch is the security framing — 'an unauthenticated way to kill the process' is a conditional pattern that requires an application to route untrusted input into these specific functions, and no affected downstream consumer, incident or advisory is presented to substantiate the exposure at scale. Contest and self-port incentives push the narrative slightly further than the evidence of real-world impact carries it.
Disclosed contest entry plus promotion of the author's competing port
Two incentives are visible on the page. The piece is explicitly a submission to DEV's Summer Bug Smash: Clear the Lineup, powered by Sentry, which rewards finding and writing up bugs. And the author maintains a Rust port whose repository, decision log and fuzzer are linked at the close, with the divergence from the original documented as an advantage (D-022 returns a finite list). Both are disclosed rather than hidden, which limits how much they discount the technical content, but they align with framing the defect as severe and long-lived.
High confidence in the defect, low confidence in its scale
The existence and mechanism of the hang are about as well evidenced as a single-source report can be — quoted code, deterministic repro, exit codes, a verified negative control. Confidence drops on everything downstream of that: how many systems pass untrusted palette sizes, whether the maintainer will merge, and whether the described denial-of-service warrants an advisory are all unaddressed by the one available publisher.
build
A one-ULP divergence, caught because a fifteen-year-old test suite refused an epsilon1 distinct publisher
build
98ms repo maps: what moving symbol indexing out of Node actually buys an agent1 distinct publisher
build
The Postgres MCP server in tens of thousands of installs stopped shipping in December 20241 distinct publisher
build
A GAN beauty filter is a device budget allocation, not a feature toggle1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 17, 2026