Web Security Cheatsheet

Deserialization and Uploads

Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Unsafe deserialization

Deserialization becomes dangerous when the format can describe which types to construct and which methods to call, not just data. Feeding attacker-controlled bytes to such a deserializer is handing it a program. This sits under A08:2025 Software or Data Integrity Failures.

# Vulnerable: pickle reconstructs arbitrary objects by calling arbitrary code
import pickle
state = pickle.loads(request.data)

The rule: never deserialize untrusted data with a format that can instantiate arbitrary types. JSON cannot, which is why JSON is the default answer.

LanguageAvoid on untrusted inputUse instead
Pythonpickle, marshal, shelve, dill, yaml.load with the unsafe loaderjson, yaml.safe_load, an explicit schema
JavaObjectInputStream.readObject, XStream and Jackson with default typingJackson or Gson with explicit types, Protobuf
PHPunserialize() on user datajson_decode(), or unserialize($s, ['allowed_classes' => false])
RubyMarshal.load, YAML.loadJSON.parse, YAML.safe_load
.NETBinaryFormatter (removed in .NET 9), NetDataContractSerializer, LosFormatterSystem.Text.Json, DataContractSerializer with known types
Nodenode-serialize, eval-based revivers, vm on user codeJSON.parse, and validate with zod or similar
# Fixed: a format that carries data only, plus schema validation
import json
from pydantic import BaseModel

class Prefs(BaseModel):
    theme: str
    page_size: int

prefs = Prefs.model_validate(json.loads(request.data))

When you must accept a serialized object

Sometimes a binary or stateful format is a requirement. Then integrity is the control, and it must be checked before the bytes reach the parser.

import hmac, hashlib
# Sign on the way out
sig = hmac.new(KEY, blob, hashlib.sha256).digest()
# Verify BEFORE deserializing, in constant time
if not hmac.compare_digest(sig, provided_sig):
    raise ValueError("integrity check failed")
obj = deserialize(blob)   # only now
ControlDetail
HMAC or signature check firstVerify, then parse. Parsing to find the signature defeats it.
Type allowlistJava ObjectInputFilter, PHP allowed_classes, Jackson's PolymorphicTypeValidator
Run the parser with least privilegeSeparate process, no credentials, restricted egress
Resource limitsDepth, size, and entry-count caps stop decompression and billion-laughs style resource exhaustion
Log and alert on failuresA deserialization exception is a strong signal, not noise

Also treat client-side state as untrusted even when you signed it. A signed cookie proves you produced it, not that its contents are still valid for this user.

Other integrity failures

A08 covers more than deserialization. The shared theme is trusting an artifact whose provenance was never verified.

FailureFix
Unsigned auto-updateSign releases, verify the signature before applying
Unpinned dependenciesLockfiles with hashes, npm ci, pip install --require-hashes
Third-party script with no integrity checkSubresource Integrity, or self-host
Mutable CI artifactsImmutable, content-addressed storage, provenance attestations
Trusting a CI environment variable as authorizationScope tokens, use OIDC federation rather than long-lived keys

Compute the hash from the exact file you intend to pin, then paste it in. Do not copy a hash from anywhere else.

curl -s https://cdn.example/lib-1.2.3.js \
  | openssl dgst -sha384 -binary | openssl base64 -A
<script src="https://cdn.example/lib-1.2.3.js"
        integrity="sha384-<output of the command above>"
        crossorigin="anonymous"></script>

SRI only helps if the resource is immutable and versioned. A @latest URL with an integrity hash will simply break on the next release, which is a signal it was the wrong URL.

File upload risks

An upload endpoint accepts attacker-chosen bytes with an attacker-chosen name and type, then usually stores and later serves them. Four distinct problems.

RiskWhat goes wrong
ExecutionThe file lands somewhere the server will interpret, so a script gets run
Stored XSSHTML or SVG is served inline from your origin, so its script runs with your origin's privileges
Path traversal / overwriteThe filename contains ../ or an absolute path and escapes the upload directory
Resource exhaustionHuge files, or archives and images that expand enormously when processed
# Vulnerable: attacker controls the path and the extension
path = os.path.join(UPLOAD_DIR, request.files["f"].filename)
request.files["f"].save(path)

Upload hardening

Layer these. Each one closes a different failure mode.

Generate the stored name yourself. Never use the client's filename as a path component. Keep the original only as a display label, and only after sanitizing it.

import uuid, pathlib
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".pdf"}

ext = pathlib.PurePosixPath(upload.filename).suffix.lower()
if ext not in ALLOWED_EXT:
    abort(400)
stored = f"{uuid.uuid4().hex}{ext}"        # server-chosen name
dest = pathlib.Path(UPLOAD_DIR) / stored   # no traversal possible

Validate the content, not the claim. The Content-Type header and the extension are both client supplied. Sniff the real type and require that it agrees with the extension.

import magic
detected = magic.from_buffer(upload.read(2048), mime=True)
upload.seek(0)
EXPECTED = {".png": "image/png", ".jpg": "image/jpeg", ".pdf": "application/pdf"}
if detected != EXPECTED[ext]:
    abort(400)

Re-encode images. Decoding and re-writing an image strips embedded payloads and metadata, and proves the file really is an image. It also normalizes format quirks.

from PIL import Image
Image.MAX_IMAGE_PIXELS = 50_000_000        # guard huge-dimension images
img = Image.open(upload.stream)
img.verify()                               # then reopen to actually process
Image.open(upload.stream).convert("RGB").save(dest, format="PNG")

Store outside the web root, and serve from a separate origin. Files in a directory the application server can execute is the classic remote code execution path. Serving them from your own origin is the stored-XSS path.

Content-Type: application/octet-stream        # never text/html for user files
Content-Disposition: attachment; filename="report.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox
ControlReason
Store outside the web root, or in object storageNothing to execute
Serve from a distinct domain, not a subdomainAn XSS there cannot reach your cookies, and SameSite does not treat a subdomain as cross-site
Content-Disposition: attachmentThe browser downloads instead of rendering
X-Content-Type-Options: nosniffStops MIME sniffing turning a text file into HTML
A fixed Content-Type, never the client'sAn HTML Content-Type on user bytes is stored XSS
Strip execute permissions0644, and no interpreter mapping for the directory
Enforce a size limit at the proxyCaps the request before your code allocates anything
Scan with antivirus where relevantEspecially when files are shared between users
Rate limit and quota per accountBounds abuse and storage cost

SVG deserves its own line. An SVG is XML that can contain script and external references, so serving one inline is equivalent to serving HTML. Either sanitize it through a maintained sanitizer, serve it as an attachment from a separate origin, or rasterize it to PNG on upload.

Archives and nested formats. A zip, a tar, or an office document is a container. Cap the entry count, the uncompressed size, and the compression ratio, reject entries with absolute paths, .. segments, or symlinks, and never extract with a library call that trusts the archive's own paths.

Recognizing it in review

grep -rnE "pickle\.loads|yaml\.load\(|marshal\.loads|Marshal\.load" src/
grep -rnE "unserialize\(|readObject\(|BinaryFormatter|enableDefaultTyping" src/
grep -rnE "\.filename|originalname|secure_filename|path\.join\(.*upload" src/
grep -rn "MAX_CONTENT_LENGTH\|client_max_body_size\|limits:" . | head

Checklist:

- No untrusted input reaches pickle, yaml.load, readObject, unserialize, or BinaryFormatter. - Where a serialized object must be accepted, integrity is verified before parsing and a type allowlist is in place. - Dependencies are pinned with hashes, and third-party scripts use SRI or are self-hosted. - Stored filenames are server-generated, and the extension comes from an allowlist. - Content type is detected from the bytes and cross-checked with the extension. - Images are re-encoded, and pixel dimensions are bounded. - Files are stored outside the web root and served from a separate origin with attachment, nosniff, and a fixed Content-Type. - SVG is sanitized or rasterized, never served inline from an origin that has cookies. - Request size limits exist at the proxy, and archive extraction caps entries, size, and ratio.

Further reading: the OWASP Deserialization Cheat Sheet and File Upload Cheat Sheet.