Safe Deserialization in Python and Node.js

Deserialization vulnerabilities have an unusual property: exploitation usually needs no bug in your code at all. The payload names types that already exist in the process, and the language’s own construction machinery does the rest. That is why the remedy is not careful parsing but a change of format — a format that cannot name a type cannot select a code path, no matter how creative the sender.

This guide covers the practical migration: finding every external parse, replacing object-graph loaders with schema-validated values, handling the internal cases where a binary format genuinely earns its place, and gating the dangerous calls so they cannot return. It is part of the File Upload & Deserialization Security guide within Vulnerability Patterns & Web Mitigation Strategies, and shares its underlying rule with injection attack prevention: keep data and instructions in separate channels.

Prerequisites

  • A Python or Node.js service that accepts data from outside the process
  • A schema validation library — pydantic, zod, ajv or equivalent
  • A static analysis runner in the pipeline that can fail on a pattern match
  • An inventory of caches, queues and inter-service channels the process reads from

Expected Outcomes

  • Every external parse identified, with its trust source recorded
  • Object-graph loaders removed from all externally reachable paths
  • Remaining binary formats bound to a schema and authenticated before parsing
  • A CI rule that fails the build when an unsafe loader reappears

Step 1: Audit Every Parse of External Bytes

Start with the call sites, not with the endpoints. Most services have fewer parsers than they expect, and one of them is usually somewhere nobody thought of.

# Python: the calls that construct objects from bytes.
grep -rnE 'pickle\.loads|pickle\.load|yaml\.load\(|jsonpickle|shelve\.open|marshal\.loads' --include='*.py' .

# Node: the equivalents, plus the merge helpers that lead to prototype pollution.
grep -rnE 'node-serialize|funcster|serialize-javascript|JSON\.parse\(.*reviver|_\.merge\(|Object\.assign\(\{\}' \
  --include='*.js' --include='*.ts' .

For each hit, record three facts: where the bytes come from, who can influence them, and what would happen if the payload were chosen by an adversary. The third column is the one that reorders the work — a parse in a request handler and a parse in a cache read are equally dangerous once the cache is shared.

Where the Bytes Come From Decides the Format You May Use Five byte sources ordered by exposure. Request bodies and uploaded files are directly attacker-chosen. Message queues and shared caches are indirectly reachable through any component that can write to them. Configuration files are as trusted as the deploy pipeline that produced them. Only the last tier may use a format that names types. Request bodies and query parameters — chosen by the caller value-only formats, schema validated; never anything that can name a type Uploaded files — chosen byte for byte by the caller parse with a format-specific reader in a sandbox; never a generic object loader Queues and topics — as trusted as every producer that can publish schema registry plus a signature or authenticated transport, then validate Shared caches and session stores — as trusted as everything sharing them store values, not objects; a key-injection flaw otherwise becomes code execution Deploy-time configuration — as trusted as the pipeline that produced it

Step 2: Replace Object-Graph Loaders With Validated Values

The Python change is usually mechanical:

Three Replacements That Cover Almost Every Call Site An object-graph load of session state becomes a value parse plus a schema-validated model. An unsafe configuration loader becomes the value-only variant. And an object merge of request data becomes explicit field assignment after validation, which removes the prototype-pollution path at the same time. Object-graph load of stored session state replace with a value parse and a model that forbids unknown keys Unsafe configuration loader on request data replace with the value-only loader — scalars, lists and maps only Object merge of a parsed request body replace with explicit assignment of named, validated fields What each replacement buys the payload can no longer name a type, so the parser stops being an execution primitive
# BEFORE — the payload decides which objects get built.
import pickle, yaml
state = pickle.loads(redis.get(f"session:{sid}"))
config = yaml.load(request.data)                  # unsafe loader

# AFTER — values in, schema-validated model out.
import json, yaml
from pydantic import BaseModel, ConfigDict

class SessionState(BaseModel):
    model_config = ConfigDict(extra="forbid")     # unknown keys are a rejection
    user_id: str
    tenant_id: str
    issued_at: int

raw = json.loads(redis.get(f"session:{sid}") or "{}")
state = SessionState.model_validate(raw)          # types and shape enforced here
config = yaml.safe_load(request.data)             # scalars, lists and maps only

In Node the loader is rarely the problem; the merge is:

import { z } from 'zod';

const Patch = z.object({
  displayName: z.string().min(1).max(80),
  locale: z.enum(['en', 'de', 'fr']),
}).strict();                                       // unknown keys rejected

// BEFORE — a payload containing a prototype key mutates every object in the process.
Object.assign(user, JSON.parse(body));

// AFTER — validate, then copy named fields explicitly.
const patch = Patch.parse(JSON.parse(body));
user.displayName = patch.displayName;
user.locale = patch.locale;

Rejecting unknown keys is not pedantry. It is what turns “the payload contained something unexpected” from a silent mutation into a 400 with a field name in it.


Step 3: Sign and Schema-Bind the Binary Formats That Remain

Some internal paths genuinely need compactness. Keep them, but make the parser’s job unambiguous.

import hmac, hashlib
from google.protobuf.message import DecodeError

def read_event(envelope: bytes, key: bytes) -> Event:
    if len(envelope) < 32:
        raise Rejected("truncated envelope")
    tag, body = envelope[:32], envelope[32:]

    expected = hmac.new(key, body, hashlib.sha256).digest()
    if not hmac.compare_digest(tag, expected):     # authenticate BEFORE parsing
        raise Rejected("bad signature")

    event = Event()                                # one known message type
    try:
        event.ParseFromString(body)
    except DecodeError as exc:
        raise Rejected("malformed event") from exc
    return event

Order matters: verify, then parse. Parsing first hands unauthenticated bytes to the decoder, which is where the memory-safety issues in binary parsers live. And note that the message type is chosen by the reader, not named in the payload — that is the property that makes this acceptable where a generic loader would not be.

Verify First, Because Parsers Are Where Memory Bugs Live Parsing before verification hands unauthenticated bytes to a binary decoder, which is exactly where malformed-input vulnerabilities live. Verifying first means the decoder only ever sees bytes a key holder produced. The message type is chosen by the reader rather than named in the payload, which is what makes a binary format acceptable at all here. Parse, then verify the tag the decoder has already processed attacker-controlled bytes before any check runs Verify the tag, then parse the decoder only ever sees bytes a key holder produced The reader chooses the message type the payload never names what to construct — the property that makes this acceptable

Verification

Prove that the dangerous shapes are refused and that legitimate traffic still flows.

def test_object_graph_payload_is_refused(client):
    # A payload that would construct an object under the old loader.
    hostile = b"\x80\x04\x95…"                      # any pickled object
    res = client.post("/api/state", data=hostile,
                      headers={"Content-Type": "application/octet-stream"})
    assert res.status_code == 415                   # format not accepted at all

def test_unknown_field_is_rejected(client):
    res = client.post("/api/profile", json={"displayName": "a", "isAdmin": True})
    assert res.status_code == 400
    assert "isAdmin" in res.json()["detail"][0]["loc"]

def test_prototype_key_does_not_mutate(client):
    client.post("/api/profile", json={"__proto__": {"polluted": True}})
    assert not hasattr(object(), "polluted")

Then stop the pattern from returning:

- name: Forbid unsafe deserialization
  run: |
    semgrep --error --metrics=off \
      --config p/python --config p/javascript \
      --severity ERROR \
      --exclude 'tests/fixtures/**' .

Troubleshooting

Symptom Likely cause Fix
Session reads fail after the migration Existing sessions were written in the old object format Version the stored payload, read both formats for one rotation window, then drop the old reader
Schema rejects legitimate traffic A client sends optional fields the schema does not declare Add the fields explicitly with defaults; never relax to allowing unknown keys
Static rule fires on test fixtures Fixtures deliberately contain the dangerous pattern Exclude the fixtures path explicitly rather than lowering the severity
Signature check passes on replayed messages The signed body carries no context or timestamp Include the topic, recipient and issue time inside the signed body and check them after verifying
Binary parse crashes on malformed input Parsing happens before authentication Verify the tag first; the parser should only ever see bytes you have already vouched for

Common Implementation Mistakes


Frequently Asked Questions

Is signing a serialized payload enough to make it safe?

A signature proves the payload came from a key holder; it does not make the parser safe. If the key leaks, if any signer is compromised, or if a payload signed for one context is replayed into another, an object-graph parser is once again an execution primitive. Signatures belong on internal binary formats, but always paired with a schema, so the reader builds one known shape rather than whatever the sender names.

Are JSON parsers safe by default?

The parse is, because the format expresses only values. What comes next often is not. Merging parsed input into an existing object can pollute prototypes and change behaviour process-wide; reviver functions and class-hydration helpers reintroduce type selection through the back door. Parse to a plain value, validate against a schema that rejects unknown keys, and construct your objects from the validated fields.

What about caches and session stores that serialize objects?

They are the most commonly overlooked case, precisely because the data feels internal. If an attacker can write to the store — through key injection, a shared instance, or a compromised neighbour — everything the process reads back is attacker-controlled, and an object-graph deserializer turns that into code execution. Store values rather than objects in shared caches, and treat any store your process does not exclusively own as external.