Skip to content
← Back to explainers

Python explainer

Check Outside Data Where It Enters Your Program

About 4 min to read. Allow extra time to work through examples.

Find the places where outside data reaches your code, check it there once, and turn it into typed values the rest of the program can rely on.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your web service reads a JSON request body. Anything can be in that body. A client can send a typo, and an attacker can send a request built to break your service. This handler trusts the body anyway:

import json


def greet(body: bytes) -> str:
    request = json.loads(body)
    name: str = request["name"]
    return "Hello, " + name


print(greet(b'{"name": "Ada"}'))  # Hello, Ada
print(greet(b'{"name": 42}'))     # TypeError: can only concatenate str (not "int") to str

mypy accepts this code, because json.loads returns Any. Nothing checks the annotation name: str when the code runs.

Check once, at the boundary

A trust boundary is a place where your code receives data it cannot assume is valid. A request body, an uploaded file, an environment variable and a message from another service all cross one. Check the data there, once, and turn it into a value with the type you need:

import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Greeting:
    name: str


def parse_greeting(body: bytes) -> Greeting:
    if len(body) > 1024:
        raise ValueError("request is larger than 1 KB")
    request = json.loads(body)
    if not isinstance(request, dict):
        raise ValueError("expected a JSON object")
    name = request.get("name")
    if not isinstance(name, str):
        raise ValueError("name must be a string")
    return Greeting(name=name)


def greet(greeting: Greeting) -> str:
    return "Hello, " + greeting.name

Now greet takes a Greeting, never a raw dict. Every Greeting has a str name, because parse_greeting is the only code that builds one. Keep it that way. Python does not check dataclass field types when you create an object, so the checks in the parser are what make the type true.

At each boundary, ask three questions.

Does the data have the shape and values you need?

Check that each required field is there, that each value has the right type, and that numbers are in range. Decide on purpose whether to ignore fields you did not expect or reject them. Watch for bool. JSON true becomes Python True, and isinstance(True, int) is True, so an int check alone lets it through. Limit the size of the input before you parse it, so one huge request cannot use up your memory.

Can reading the format run code?

json.loads builds only dicts, lists, strings, numbers, booleans and None. pickle.loads can call any function the data names. Unpickling bytes from someone you do not trust lets them run code on your machine. Use JSON, or another plain data format, for anything from outside.

Will a value end up inside a SQL query or a command?

A value pasted into SQL text, or into a shell command, can change what the query or command does. This is called injection. Give the value to the database separately, through a ? placeholder, and the database treats it as plain data:

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT)")
name = "Robert'); DROP TABLE users;--"
db.execute("INSERT INTO users (name) VALUES (?)", (name,))
print(db.execute("SELECT name FROM users").fetchall())
# [("Robert'); DROP TABLE users;--",)]

A placeholder can stand only for a value, never for a table or column name. Pick the name from a fixed set of allowed names, called an allowlist. For commands, pass the arguments as a list, so no shell reads them.

Check your understanding Your function takes a Greeting built by parse_greeting. Should it check again that greeting.name is a str? Show answer

No. Checking once at the boundary is the point. The type tells the rest of the code what the parser already checked, and mypy enforces it. Keep every Greeting coming from the parser, so the type stays true. Python itself would let Greeting(name=42) through, though mypy rejects that call.

Going further

The rest of this track works through each question. Turn Outside Data into a Typed Record, the next exercise, has you write a parser like this one. In later items, you read settings from environment variables, decode bytes, load data formats safely, run commands without a shell, and keep secrets out of logs. The OWASP input validation cheat sheet and the pickle security warning cover the same topics in more depth.

step 1 of 16

Config, Logs and Untrusted Input

Up next Turn Outside Data into a Typed Record