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.
| Language | Avoid on untrusted input | Use instead |
|---|---|---|
| Python | pickle, marshal, shelve, dill, yaml.load with the unsafe loader | json, yaml.safe_load, an explicit schema |
| Java | ObjectInputStream.readObject, XStream and Jackson with default typing | Jackson or Gson with explicit types, Protobuf |
| PHP | unserialize() on user data | json_decode(), or unserialize($s, ['allowed_classes' => false]) |
| Ruby | Marshal.load, YAML.load | JSON.parse, YAML.safe_load |
| .NET | BinaryFormatter (removed in .NET 9), NetDataContractSerializer, LosFormatter | System.Text.Json, DataContractSerializer with known types |
| Node | node-serialize, eval-based revivers, vm on user code | JSON.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
| Control | Detail |
|---|---|
| HMAC or signature check first | Verify, then parse. Parsing to find the signature defeats it. |
| Type allowlist | Java ObjectInputFilter, PHP allowed_classes, Jackson's PolymorphicTypeValidator |
| Run the parser with least privilege | Separate process, no credentials, restricted egress |
| Resource limits | Depth, size, and entry-count caps stop decompression and billion-laughs style resource exhaustion |
| Log and alert on failures | A 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.
| Failure | Fix |
|---|---|
| Unsigned auto-update | Sign releases, verify the signature before applying |
| Unpinned dependencies | Lockfiles with hashes, npm ci, pip install --require-hashes |
| Third-party script with no integrity check | Subresource Integrity, or self-host |
| Mutable CI artifacts | Immutable, content-addressed storage, provenance attestations |
| Trusting a CI environment variable as authorization | Scope 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.
| Risk | What goes wrong |
|---|---|
| Execution | The file lands somewhere the server will interpret, so a script gets run |
| Stored XSS | HTML or SVG is served inline from your origin, so its script runs with your origin's privileges |
| Path traversal / overwrite | The filename contains ../ or an absolute path and escapes the upload directory |
| Resource exhaustion | Huge 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
| Control | Reason |
|---|---|
| Store outside the web root, or in object storage | Nothing to execute |
| Serve from a distinct domain, not a subdomain | An XSS there cannot reach your cookies, and SameSite does not treat a subdomain as cross-site |
Content-Disposition: attachment | The browser downloads instead of rendering |
X-Content-Type-Options: nosniff | Stops MIME sniffing turning a text file into HTML |
A fixed Content-Type, never the client's | An HTML Content-Type on user bytes is stored XSS |
| Strip execute permissions | 0644, and no interpreter mapping for the directory |
| Enforce a size limit at the proxy | Caps the request before your code allocates anything |
| Scan with antivirus where relevant | Especially when files are shared between users |
| Rate limit and quota per account | Bounds 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.