FastAPI · Insecure deserialization

Insecure deserialization in FastAPI

FastAPI turns JSON into validated Pydantic models and never constructs arbitrary objects from a request, so the deserialization risk in a FastAPI service is concentrated where it stops using that path: file uploads and queues.

Class
Insecure deserialization in Python
CWE
CWE-502
web framework
FastAPI documentation
On this page

How it arises in FastAPI

Model uploads in ML services. FastAPI is a common front for inference. An admin-looking endpoint accepts a new model as UploadFile and loads it. Many model and array formats are pickle underneath, and pickle.load(upload.file) on a client's file is code execution with an application/octet-stream content type. The Python documentation states it plainly: the pickle module is not secure, and you should only unpickle data you trust.

Raw-body endpoints. A handler reads await request.body() to accept "any Python object" from an internal client, bypassing Pydantic on purpose for speed or flexibility. Internal clients forward user data.

Task queues. Long work moves to Celery or a similar worker. Celery accepts only JSON by default; a service that needs to pass dataframes or numpy arrays enables the pickle serializer, and from then on the broker is a way to run code in the worker.

Result caches. An endpoint memoises an expensive response by pickling it into Redis and loading it on the next request.

YAML configuration endpoints. Rule engines and workflow APIs accept YAML and load it with a full loader.

A subtle point about Depends: authentication usually runs as a dependency, and it is easy to protect the JSON routes and forget the one binary upload route registered on a different router.

Vulnerable and fixed

import pickle

from fastapi import FastAPI, UploadFile

app = FastAPI()
MODELS = {}


@app.post("/models/{name}")
async def upload_model(name: str, file: UploadFile):
    MODELS[name] = pickle.load(file.file)
    return {"loaded": name}

Do not load uploaded files as pickles at all. Accept weights in a data-only format, verify the artifact against a digest you published through a separate channel, and keep the object construction in your own code. The example below takes JSON weights and checks a SHA-256 allowlist before parsing:

import hashlib
import json

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()
MODELS = {}
APPROVED_SHA256 = {"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}


@app.post("/models/{name}")
async def upload_model(name: str, file: UploadFile):
    blob = await file.read()
    if hashlib.sha256(blob).hexdigest() not in APPROVED_SHA256:
        raise HTTPException(status_code=400, detail="artifact is not approved")
    weights = json.loads(blob)
    if not isinstance(weights, dict):
        raise HTTPException(status_code=400, detail="unexpected format")
    MODELS[name] = weights
    return {"loaded": name}

If a pickle-based format is unavoidable, load it only from storage that the service account cannot write to and that a release pipeline, not an API, populates.

What FastAPI already does for you

Request bodies declared as Pydantic models are parsed as JSON and validated field by field; no class is instantiated because the client named it. UploadFile gives you a file object and metadata without interpreting the content. Dependencies make it straightforward to require authentication on a whole router. Celery, commonly paired with FastAPI, defaults to JSON and rejects other content types unless you opt in through accept_content.

What es-python adds

Uploaded bytes reach the service over the connection and pass through the multipart parser before the loader reads them. A small upload stays in memory on the way and es-python follows it to the loader. A large one is spooled to a temporary file first, so switch on the file source as well and bytes read back from disk are followed too. When the data reaches the deserializer es-python records pickle load for a file-object load or pickle loads for a bytes load, tagged rce in the events list because a pickle can run code as it loads, with the trace of the endpoint. Request data is graded Critical.

The fixed endpoint calls json.loads() with no hook, which is not a deserialization sink, so it reports nothing in this class. The file source also covers model files that arrive by other routes: a pickle read from a download directory or a shared volume is then reported when it is loaded, so unsafe model loading stays visible.

Two things to expect. If you run a pickle-serializing worker, messages from the broker arrive over a socket and each unpickle is reported; that is the accurate picture of what the worker trusts. And because any byte of a pickle can be an instruction, EyalSec does not suppress these events on the value. With a Raise rule on pickle loads, the load raises a RuntimeError before the payload is processed.

Review checklist

  1. Search for pickle.load, pickle.loads, yaml.load( and loaders built on pickle.
  2. List every endpoint that takes UploadFile, bytes or Request instead of a model.
  3. Confirm those routes have the same auth dependencies as the JSON routes.
  4. Keep accept_content = ["json"] on workers.
  5. Load model artifacts from read-only, release-managed storage.

References

See it on your own FastAPI code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo