Deserialization of untrusted data

Code execution from untrusted data passed to node-serialize

Description

The unserialize function in node-serialize 0.0.4 restores more than plain data. It first applies JSON.parse to string input, then recursively walks objects and arrays and evaluates string properties beginning with _$$ND_FUNC$$_. Passing request-controlled strings, objects or arrays to it can therefore execute arbitrary JavaScript in the server process.

The package's security advisory lists no patched release. Remove node-serialize and executable object restoration from request-processing paths, and handle external input only as data.

Cause of the risk

  • unserialize interprets its function marker as executable code, not an ordinary string.
  • Applying JSON.parse first does not help if the resulting object is then passed to unserialize; the marker strings remain available for evaluation.
  • A signature can detect unauthorized changes, but it does not make an authorized malicious producer, a compromised key, contaminated existing records or executable decoding safe.
  • Containers and least privilege may limit harm. The Node.js Permission Model is not a security boundary against malicious code.

Potential impact

  • Arbitrary code or commands executed with the application's permissions
  • Access to files, environment variables, credentials and internal services
  • Data exposure or alteration and compromise of other systems
  • Repeated execution when a malicious record remains in storage

Remediation

  1. Remove node-serialize from request-processing code and runtime dependencies. There is no patched version to upgrade to.
  2. Parse external data with a data-only format such as JSON. Apply an endpoint-appropriate byte limit before parsing, then validate required fields, types, lengths, ranges and unexpected extra fields. TypeScript interfaces, annotations, generic constraints and assertions disappear at runtime and do not provide validation.
  3. Copy only validated fields into a new data object. Do not pass parsed or validated values to unserialize, eval, Function or dynamic operation selection.
  4. Migrate existing node-serialize records to a data-only format through a controlled offline process separate from network requests. If a temporary migration must decode old records, verify the exact stored bytes and authorize their producer first. This is not a permanent substitute for removing executable deserialization.
  5. Run migration with least privilege, then search and test to confirm that unserialize calls and the dependency are removed.

Examples

Before

javascript
const express = require("express");
let serialize = require("node-serialize");
const app = express();

app.use(express.json({ limit: "64kb", strict: true }));

app.post("/profiles", (req, res) => {
  const profile = serialize.unserialize(req.body);
  res.json(profile);
});

The caller can include function markers in the body. Object input is therefore dangerous as well as string input.

After

javascript
const express = require("express");
const app = express();

app.use(express.json({ limit: "64kb", strict: true }));

const allowedProfileKeys = new Set(["displayName", "age"]);

function parseProfile(body) {
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
    throw new TypeError("profile must be an object");
  }

  const keys = Object.keys(body);
  if (
    keys.length !== 2 ||
    keys.some((key) => !allowedProfileKeys.has(key)) ||
    !Object.hasOwn(body, "displayName") ||
    !Object.hasOwn(body, "age")
  ) {
    throw new TypeError("profile has an invalid shape");
  }

  if (
    typeof body.displayName !== "string" ||
    body.displayName.length < 1 ||
    body.displayName.length > 80 ||
    !Number.isInteger(body.age) ||
    body.age < 0 ||
    body.age > 130
  ) {
    throw new TypeError("profile has invalid values");
  }

  return {
    displayName: body.displayName,
    age: body.age,
  };
}

app.post("/profiles", (req, res) => {
  try {
    const profile = parseProfile(req.body);
    res.status(201).json(profile);
  } catch {
    res.status(400).json({ error: "invalid profile" });
  }
});

This example limits the request size, validates the exact data shape and domain ranges, and copies only permitted values into a new object. 64kb and the field constraints are examples; tighten them to match the endpoint's actual data contract.

References