KAHIBARO
Discord Login Register

15.10 Input Validation

Why Input Validation Matters

Input validation is checking that any data coming into your backend is what you expect, before you use it.

Every external input is potentially dangerous:

If you do not validate, attackers can:

Rule: Treat all external input as untrusted and validate it before using it.

Throughout this chapter, examples use Python-like pseudocode and JSON, but the same ideas apply to any backend language or framework.


Types of Input Validation

There are several ways to validate input. You usually combine them.

Whitelisting vs Blacklisting

Whitelisting is much safer.

Rule: Prefer whitelisting (allow known good patterns) over blacklisting (forbid known bad patterns).

Simple Example

Whitelisting username:

python
import re
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")
def validate_username(username: str) -> bool:
    return bool(USERNAME_RE.match(username))

Blacklisting dangerous substring:

python
def is_safe_comment(comment: str) -> bool:
    return "<script>" not in comment.lower()

The second example is weak. Attackers can use variations like <ScRiPt> or other ways to execute scripts.


Syntactic vs Semantic Validation

You need both.

Examples

Syntactic:

python
def validate_age(value: str) -> int:
    try:
        age = int(value)
    except ValueError:
        raise ValueError("age must be an integer")
    return age

Semantic:

python
def check_age(age: int) -> None:
    if age < 18:
        raise ValueError("age must be at least 18")

Use syntactic validation first, then semantic.


Client-Side vs Server-Side Validation

Client-side validation improves user experience, but cannot be trusted. Users can bypass it with:

Rule: Never rely on client-side validation alone. Always validate again on the server.

Example:

Validation by Input Type

Different types of input need different checks.

Numbers

Typical checks:

Example: price field.

python
def validate_price(value: str) -> float:
    try:
        price = float(value)
    except ValueError:
        raise ValueError("price must be a number")
    if price < 0:
        raise ValueError("price cannot be negative")
    if price > 1_000_000:
        raise ValueError("price too large")
    return price

For counts or IDs, use integers and disallow negatives:

python
def validate_quantity(value: str) -> int:
    try:
        quantity = int(value)
    except ValueError:
        raise ValueError("quantity must be an integer")
    if quantity < 1:
        raise ValueError("quantity must be at least 1")
    if quantity > 1000:
        raise ValueError("quantity too large")
    return quantity

Strings

For string fields:

Example: username.

python
import re
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")
def validate_username(username: str) -> str:
    username = username.strip()
    if not USERNAME_RE.match(username):
        raise ValueError("username must be 3-20 characters, letters, numbers, underscore only")
    return username

Example: free text comment, where you allow almost anything but limit size:

python
def validate_comment(comment: str) -> str:
    comment = comment.strip()
    if len(comment) == 0:
        raise ValueError("comment cannot be empty")
    if len(comment) > 5000:
        raise ValueError("comment too long")
    return comment

Enums and Controlled Values

Some fields should only allow a small set of values.

Examples:

Rule: For fields with limited options, use enums or explicit lists, not free text.

Example:

python
ALLOWED_ROLES = {"user", "admin", "moderator"}
def validate_role(role: str) -> str:
    role = role.strip().lower()
    if role not in ALLOWED_ROLES:
        raise ValueError("invalid role")
    return role

This also helps prevent logical bugs and privilege escalation.


Dates and Times

For dates and times:

Example: parse ISO 8601 date and ensure it is not in the past.

python
from datetime import datetime, date
def validate_booking_date(value: str) -> date:
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        raise ValueError("invalid date format, expected ISO 8601")
    today = date.today()
    if parsed.date() < today:
        raise ValueError("booking date cannot be in the past")
    return parsed.date()

Also consider:

Emails, URLs, and Other Structured Text

Emails, URLs, phone numbers, etc. have structure.

Email

For security purposes, a simple pattern is often enough. Do not try to implement the full email standard.

python
import re
EMAIL_RE = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
def validate_email(email: str) -> str:
    email = email.strip().lower()
    if not EMAIL_RE.match(email):
        raise ValueError("invalid email address")
    if len(email) > 254:
        raise ValueError("email too long")
    return email

You still need to verify emails via a confirmation link when needed.

URL

Use a parser instead of complex regex.

python
from urllib.parse import urlparse
def validate_url(url: str) -> str:
    url = url.strip()
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise ValueError("invalid scheme")
    if not parsed.netloc:
        raise ValueError("invalid URL, missing host")
    return url

For security, you may also restrict:

Validating HTTP Inputs

Path and Query Parameters

These come as strings and must be converted and checked.

Example: /users/{user_id} where user_id is an integer:

python
def validate_user_id(value: str) -> int:
    try:
        user_id = int(value)
    except ValueError:
        raise ValueError("user_id must be an integer")
    if user_id <= 0:
        raise ValueError("user_id must be positive")
    return user_id

Example: pagination query params page and page_size:

python
def validate_pagination(page: str | None, page_size: str | None) -> tuple[int, int]:
    page = int(page) if page is not None else 1
    page_size = int(page_size) if page_size is not None else 20
    if page < 1:
        raise ValueError("page must be >= 1")
    if not 1 <= page_size <= 100:
        raise ValueError("page_size must be between 1 and 100")
    return page, page_size

JSON Request Bodies

For JSON, define a schema: what fields are required, their types, and constraints.

Example: registration request:

json
{
  "email": "user@example.com",
  "password": "abc12345",
  "name": "John Doe"
}

Validation steps:

  1. Check body is valid JSON.
  2. Check that required fields exist.
  3. Check types (string, number, object, etc.).
  4. Apply rules per field (lengths, formats).
  5. Reject unknown fields if your API requires strictness.

In Python-like pseudocode:

python
def validate_registration(body: dict) -> dict:
    # required fields
    for field in ("email", "password", "name"):
        if field not in body:
            raise ValueError(f"missing field: {field}")
    email = validate_email(body["email"])
    password = validate_password(body["password"])
    name = validate_name(body["name"])
    return {"email": email, "password": password, "name": name}

Using frameworks like FastAPI with Pydantic, you will typically express this as a model and let the framework run validation for you. The principle is the same.


Form Data

Form data often comes from HTML forms, usually as strings.

Common pitfalls:

Treat form fields exactly like any other external input: parse, normalize, validate.

Example: login form:

python
def validate_login_form(form: dict) -> dict:
    username = form.get("username", "").strip()
    password = form.get("password", "")
    if not username:
        raise ValueError("username required")
    if not password:
        raise ValueError("password required")
    if len(username) > 50:
        raise ValueError("username too long")
    return {"username": username, "password": password}

File Uploads

Files are especially risky. Validation involves:

Rule: Never trust only the file extension or the client-provided MIME type.

Example of size check:

python
MAX_AVATAR_SIZE = 2 * 1024 * 1024  # 2 MB
def validate_avatar(file_bytes: bytes, filename: str, content_type: str) -> None:
    if len(file_bytes) > MAX_AVATAR_SIZE:
        raise ValueError("file too large")
    if content_type not in ("image/jpeg", "image/png"):
        raise ValueError("unsupported file type")
    # Optional: inspect magic bytes or use an image library to verify

Also consider:

Preventing Injection with Validation

Input validation reduces the chance of injection attacks. It is not enough by itself, but it is a strong first layer.

SQL Injection

You already know from the SQL Injection chapter that you must use parameterized queries or ORM features. Validation strengthens this:

Example of safe and validated query:

python
def get_user(db, raw_user_id: str):
    user_id = validate_user_id(raw_user_id)
    # safe parameterized query
    return db.execute("SELECT * FROM users WHERE id = %s", (user_id,))

Rule: Always combine validation with parameterized queries or ORM. Do not rely on validation alone.


Cross-Site Scripting (XSS)

Input validation helps reduce XSS but does not replace output encoding.

Typical strategies:

Examples:

Remember, output encoding (for example, HTML escaping) is the main defense against XSS. Validation is an additional barrier.


Safe Defaults and Limits

Validation is also about setting safe boundaries.

Length and Size Limits

Set length limits everywhere you can:

This helps:

Example policies (these are examples, not universal rules):

ItemExample limit
Username3 to 30 chars
Password8 to 128 chars
Emailup to 254 chars
Comment bodyup to 5,000 chars
JSON body sizeup to 1 MB
Uploaded imageup to 5 MB

Apply limits close to the entry point if possible, for example:

Default Values vs Required Fields

For optional fields you can:

Examples:

Be explicit in your API documentation about what is required and what has defaults.


Error Messages and Security

Validation errors become part of your API responses. They should help legitimate users, but not leak sensitive information.

What to Include

Good error messages:

Example of a good error for a public API:

json
{
  "error": "validation_error",
  "details": [
    {"field": "email", "message": "invalid email address"},
    {"field": "password", "message": "must be at least 8 characters"},
    {"field": "age", "message": "must be an integer >= 18"}
  ]
}

Bad examples:

Rule: Validation errors should be clear but generic, and must not expose stack traces, SQL queries, or secrets.

Differentiate validation errors (client errors, status 400) from server errors (status 500).


Centralizing Validation

Instead of scattering manual checks everywhere, centralize validation:

Benefits:

Example Concept with Models

Imagine a user registration model:

python
from pydantic import BaseModel, EmailStr, constr
class RegisterUser(BaseModel):
    email: EmailStr
    password: constr(min_length=8, max_length=128)
    name: constr(min_length=1, max_length=100)

Your framework then:

Even if you do not use Pydantic, aim for a similar pattern in your framework of choice.


Practical Checklist for Input Validation

You can apply this quick checklist to each new endpoint:

  1. Identify all inputs:
    • Path params
    • Query params
    • Headers
    • Cookies
    • Body (JSON / form)
    • Files
  2. Define schema:
    • Required fields and types.
    • Min / max length or value.
    • Allowed values (enums).
    • Exact format (for example, ISO date).
  3. Add syntactic checks:
    • Type conversion.
    • Patterns and formats.
  4. Add semantic checks:
    • Business rules (ranges, dates, uniqueness constraints where appropriate).
  5. Set limits:
    • Field-level size limits.
    • Total request size limit.
    • File size limit.
  6. Use safe defaults:
    • For optional fields like pagination, sorting.
  7. Return safe errors:
    • Explain what is wrong with input.
    • Do not leak internals.
  8. Combine with other defenses:
    • Parameterized SQL.
    • Output encoding.
    • Authentication and authorization checks.

If you apply this consistently, you will prevent a large class of bugs and vulnerabilities before they appear.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!