{ } Programming

Validating input: defensive programming and safer code

How to validate input in code: allowlists, validation at the boundaries, parameterised queries, safe file paths and keeping secrets out of source code.

A surprisingly large share of software vulnerabilities has the same origin: the program trusts data it should not consider reliable. A form field, a parameter in a web address, an uploaded file, an external service's response. In my security work, many of the problems I come across require no sophisticated techniques to prevent: a consistent habit of treating every external input as suspect until it has been checked would be enough.

The underlying principle

Every piece of data arriving from outside your program's boundary is untrusted. Not because all users are malicious, but because the code cannot tell a typo from an attack attempt. This category includes:

  • everything arriving in HTTP requests: parameters, headers, cookies, the request body;
  • files uploaded by users, including name, size and content;
  • data read from databases shared with other systems;
  • responses from external APIs;
  • environment variables and command-line arguments, in contexts where others can control them.

An often forgotten detail: checks performed in the browser, with JavaScript or HTML form attributes, improve the user experience but protect nothing. Anyone can send a request directly to the server, bypassing them. The validation that counts is server-side.

Allowlist, not blocklist

There are two approaches to validation:

Approach How it works Problem
Blocklist Rejects values known to be dangerous Impossible to anticipate every variant
Allowlist Accepts only values matching precise rules Requires clearly defining what is valid

The allowlist approach is almost always preferable. If a field must contain an Italian postcode, you accept exactly five digits; everything else is rejected. If a parameter sets the sort order of a table, you accept only the expected column names:

SORTABLE_COLUMNS = {"date", "amount", "customer"}

def sort_by(column):
    if column not in SORTABLE_COLUMNS:
        raise ValueError(f"Invalid sort column: {column!r}")
    return column

Validating at the boundaries

Validation should happen on entry, at the point where the data enters the system, turning it as early as possible into a precise type. Instead of passing raw strings around the whole codebase, it pays to convert them straight away:

  • a date into a date object, rejecting invalid formats;
  • an amount into a decimal number, checking that it is positive and within a reasonable limit;
  • an identifier into an integer, verifying that it exists and belongs to the user requesting it.

Libraries such as Pydantic in Python let you declare these rules compactly and readably, producing clear errors when data does not comply. Past the boundary, the rest of the code works with data that is already checked and typed.

Parameterised queries, always

The best-known example of misplaced trust is SQL injection: a value entered by the user is concatenated into a query and ends up changing its structure.

# Wrong: the value becomes part of the SQL code
cursor.execute(f"SELECT * FROM customers WHERE email = '{email}'")

# Right: the value is passed separately as a parameter
cursor.execute("SELECT * FROM customers WHERE email = %s", (email,))

With a parameterised query, the database receives the query text and the values separately, and never confuses one with the other. The placeholder varies by driver (%s, ?, :name), but the principle is the same. The same goes for system commands: passing arguments as a list to subprocess.run, without shell=True, prevents special characters from being interpreted by the shell.

File paths

When a parameter determines which file to read, a value such as ../../config/secrets.env can take the program outside the intended folder. The defence is to resolve the full path and check that it is still inside the permitted folder:

from pathlib import Path

BASE = Path("/srv/documents").resolve()

def safe_path(name):
    p = (BASE / name).resolve()
    if not p.is_relative_to(BASE):
        raise PermissionError("Path not allowed")
    return p

Better still, when possible, is not using user-supplied names at all: you save the file under a system-generated identifier and keep the original name only as descriptive data.

Secrets out of the code

A related rule: passwords, API keys and tokens must not appear in the source code or in the Git repository. Once committed, a secret stays in the history even if the file is changed later. They should be read from environment variables or a secrets manager, and the local file containing them, such as .env, should be excluded with .gitignore. If a secret ends up in a repository by mistake, the only reliable fix is to revoke it and generate a new one.

Measured error messages

When input is rejected, the user must understand what to correct, but the message must not reveal internal details. "The postcode must contain five digits" is useful; a stack trace with database table names shown on the page is a gift to anyone looking for weak spots. Technical details belong in the logs, not in the response.

In short

  • Treat every piece of data from outside as untrusted, and always validate on the server.
  • Prefer allowlists of accepted values to blocklists of forbidden ones.
  • Validate and convert data at the system's boundaries, turning it into precise types straight away.
  • Always use parameterised queries and check that file paths stay within the intended folder.
  • Keep secrets out of the code and show users clear errors free of internal details.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. With degrees in computer science and psychology, he combines technical analysis with an understanding of the human factor.

Read also