Build1 publisher3 min readPublished
A default that is not a guard: tinycolor2's palette functions never return on analogous(-1)
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
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- 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.
- 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.
- Both functions decrement a counter and test it for truthiness: analogous uses for (hsl.h = (hsl.h - (part * results >> 1) + 720) % 360; --results; ) and monochromatic uses while (results--).
- A counter that never lands exactly on 0 never stops.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
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.