Safeguard
Application Security

Preventing insecure deserialization in Node.js

A 2017 node-serialize flaw let attackers turn a signed cookie into remote code execution — here's how deserialization bugs still slip into Node apps.

Safeguard Research Team
Research
5 min read

In 2017, security researchers documented a remote-code-execution technique against node-serialize version 0.0.4 that required nothing more exotic than a signed cookie an attacker could edit. The package's unserialize() function would evaluate any JavaScript object embedded in the payload, including Immediately Invoked Function Expressions — so a string like "_$$ND_FUNC$$_function(){require('child_process').exec('...')}()" executed the moment the server deserialized it. The flaw was catalogued as CVE-2017-5941 and GHSA-q4v7-4rhw-9hqm, and no patch was ever issued; the maintainer's guidance was simply never to deserialize untrusted data with the library at all. Three years later, a second, more widely-used package hit the same class of bug: serialize-javascript before version 3.1.0 shipped CVE-2020-7660, a CVSS 8.1 injection flaw in how it escaped function placeholders during serialization. Node's deserialization risk didn't end with either fix — it just moved. Plain JSON.parse() doesn't execute code, but merging its output into application state without guarding __proto__ and constructor keys opens the door to prototype pollution. This post walks through both failure modes and the patterns that actually close them.

What made node-serialize exploitable?

node-serialize was exploitable because its unserialize() function treated serialized strings as executable JavaScript, not inert data. Where JSON.stringify/JSON.parse only round-trip plain data types, node-serialize's custom format could embed function bodies, and its deserializer used a regex to detect a _$$ND_FUNC$$_ marker and then ran the enclosed code through eval-equivalent evaluation to reconstruct it. That design choice is fine when the serialized string originates entirely on the server — the problem the 2017 disclosure highlighted was applications that serialized user session data with node-serialize, signed it into a cookie, and trusted the signature alone to guarantee the cookie's content was safe. A valid signature only proves the cookie wasn't tampered with after signing; if an attacker can influence what gets serialized before signing (or forge a cookie through a separate flaw), the deserializer will still faithfully execute whatever function expression is embedded in it. The advisory's fix was procedural, not code-level: stop deserializing data you didn't fully control end to end.

Why did serialize-javascript need a separate fix in 2020?

serialize-javascript needed CVE-2020-7660 fixed because it served a different purpose than node-serialize but hit an analogous escaping failure. The package is widely used to embed JavaScript objects — including functions and regexes — directly into server-rendered HTML or webpack bundles, via a deleteFunctions option that replaces function values with a unique placeholder token during serialization. Versions before 3.1.0 generated that placeholder using an insufficiently unguessable UID, so an attacker-controlled string value elsewhere in the object could be crafted to prematurely close the placeholder's context and inject arbitrary script into the output. The GitHub advisory (GHSA-hxcc-f52p-wc94) and Snyk's tracking entry (SNYK-JS-SERIALIZEJAVASCRIPT-570062) both scored it CVSS 8.1, reflecting that the injected code could run in whatever context consumed the serialized output — a browser session in the SSR case. The maintainers fixed it in 3.1.0 by strengthening the placeholder's randomness, but any project still pinned below that version, directly or as a transitive dependency of an SSR framework, remained exposed regardless of how careful its own code was.

Does switching to JSON.parse eliminate the risk?

Switching to JSON.parse() eliminates the code-execution risk that made node-serialize dangerous, because JSON has no syntax for functions, and JSON.parse never evaluates arbitrary expressions the way an eval-based deserializer does — feeding it "_$$ND_FUNC$$_..." just produces a harmless string. But JSON.parse output is still an untrusted object, and merging or cloning it carelessly reintroduces risk in a different shape: prototype pollution. If application code does something like recursively copying every key of a parsed request body into a configuration object without excluding __proto__, constructor, or prototype, an attacker can submit {"__proto__":{"isAdmin":true}} and pollute Object.prototype for the entire process — every object in the app inherits the injected property until restart. This is a well-established, widely documented pattern rather than a single CVE, and it has been the root cause behind vulnerabilities in several popular npm merge/extend utilities over the years. JSON.parse is safe as a parser; what you do with the parsed object afterward determines whether the pattern survives.

What does a safe deserialization pattern actually look like?

A safe pattern treats anything crossing a trust boundary as data only, never as code, and validates its shape before use. Concretely: never use eval-based custom (de)serializers like the vulnerable node-serialize on attacker-reachable input; use JSON.parse/JSON.stringify for object payloads, since they're structurally incapable of carrying executable functions; and when merging parsed objects into existing state, use Object.create(null) for the target or explicitly block __proto__ and constructor as keys, or reach for a schema validator (such as zod or ajv) that allow-lists expected keys and rejects everything else. For anything that needs to carry state across a trust boundary — session tokens, password-reset links, API handoffs — prefer a signed or encrypted token format like a JWT with HMAC/RSA verification over a raw serialized object, since the token's structure is fixed and its claims are validated against a schema rather than blindly reconstructed. Finally, keep dependencies patched: both node-serialize and pre-3.1.0 serialize-javascript are still resolvable via npm install today, and a lockfile pinned before the fix carries the flaw forward silently.

How Safeguard helps

Catching this bug class requires looking at two different layers, and Safeguard runs both as part of the same connected pipeline. SAST performs source-to-sink dataflow tracing across JavaScript and TypeScript, so if untrusted input — a cookie value, a request body field — flows into a call like unserialize() or a custom eval-based reconstructor, the finding carries the full trace from source to sink rather than just flagging every occurrence of the function name. SCA resolves your full npm dependency graph, including transitive dependencies pulled in by frameworks you didn't choose directly, and matches every package version against CVE/GHSA advisories with EPSS and CISA KEV context — so a lingering node-serialize install or a serialize-javascript version below 3.1.0 surfaces as a known, prioritized finding instead of something a developer has to remember to check for by hand.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.