Secure Coding in C · intermediate · ~10 min

Input validation

- Identify the trust boundary of a program and treat everything that crosses it as untrusted - Write validators that check length, character set, and semantics before any data is used - Prefer allow-lists over deny-lists and understand why deny-lists fail - Parse integers and structured strings safely with `strtol` and manual scanning, rejecting trailing garbage and out-of-range values - Canonicalise input before comparing it, and design validators that *fail closed* - Return structured, actionable validation errors instead of silently "fixing" bad input

Overview

Input validation means checking that data is exactly what you expect before your program acts on it. It happens at the boundary of your code — the first place untrusted data arrives.

Think of your program as a building. The boundary is the front door. Input validation is the security guard who inspects everyone before they enter: right length, right shape, right contents, or they do not get in. Once someone is inside, everyone assumes they were checked at the door, so if the guard is asleep, a bad actor roams freely.

Every untrusted source must pass a validator first:

  • Command-line arguments (argv)
  • Environment variables
  • File contents you read
  • HTTP request fields, headers, and query strings
  • Bytes off a network socket
  • Even a database row, if it came from data someone else supplied

This lesson builds directly on Safe string functions. There you learned to copy and measure strings without overflowing buffers. Validation is the layer that decides whether the data deserves to be copied at all. A safe strlcpy protects you from a long string; validation protects you from a string that is the wrong shape entirely — a username full of control characters, a "number" that is actually 12; rm -rf /, or a path that escapes your directory.

In plain terms: do not trust input. In precise terms: at every trust boundary, validate length, character set, and semantics, reject anything that fails, and only then let the data flow deeper.

Why it matters

Validation matters for two reasons that reinforce each other: security and correctness.

Security. Nearly every injection attack begins with input that was never validated. SQL injection, OS command injection, format-string attacks, path traversal, and cross-site scripting all share the same root cause: attacker-controlled data reached a place where it was interpreted as code or structure instead of inert data. A validator at the boundary is the cheapest, earliest, and most reliable place to stop the entire class.

Correctness. Validation deletes whole categories of ordinary bugs. Once input has cleared the boundary, every function below it can assume the data is in range, bounded in length, and well-formed. That assumption makes the rest of the program dramatically simpler — no defensive if scattered in twenty places, because the one guard at the door already did the work.

There is also a maintenance payoff. Centralised validation gives you one place to log rejections, one place to tighten a rule, and one place to audit when a new attack appears. A program that validates "a little bit, everywhere" is nearly impossible to reason about; a program that validates once, at the boundary, is auditable.

Core concepts

1. The trust boundary

Definition. The trust boundary is the line between data your program controls and data that came from outside it. Anything crossing inward is untrusted until validated.

How it works. Data enters through a small number of doors: argv, getenv, read/fread, sockets, scanf. Draw a mental line at each door. Validation lives at the line, not scattered downstream.

   UNTRUSTED SIDE          |   TRUSTED SIDE
                           |
  argv ----\               |
  env  -----\   +----------+--------+
  files -----+->| VALIDATOR (guard)  |--> business logic
  sockets ---/   +----------+--------+   (may now assume
  stdin ----/               |            data is well-formed)
                            |
              trust boundary +

When to use / not. Validate at every boundary a program has. Do not re-validate the same data deep in trusted code — that signals your boundary is in the wrong place. Fix the boundary instead.

Pitfall. Assuming data is "internal" because it came from your own database or config file. If a user ever influenced it, it is untrusted.

Knowledge check: A function reads a filename from argv[1], opens it, and passes its contents to a parser. Name the two trust boundaries here.

2. Allow-lists beat deny-lists

Definition. An allow-list enumerates exactly what is valid and rejects everything else. A deny-list enumerates known-bad things and permits everything else.

Plain explanation. There are only a handful of valid usernames' shapes but infinitely many invalid ones. If you try to list the bad inputs, you will always miss one — a new encoding, a Unicode look-alike, a control character you forgot. Listing the good ones is a finite, closed problem.

Aspect Allow-list Deny-list
What it specifies Everything permitted Some things forbidden
Default answer Reject Accept
Fails when Rarely — too strict Often — attacker finds an unlisted bypass
Maintenance Add cases as valid inputs grow Endless chase of new attacks
Security posture Fail closed Fail open

When to use / not. Default to allow-lists for structured fields (usernames, IDs, ports, paths). Deny-lists are acceptable only as a secondary defence, never the primary one.

Pitfall. "Just strip out the bad characters." Stripping ../ from ....// leaves ../. Filtering is not validation — reject, do not repair.

Knowledge check: Why does blocking the string SELECT fail to stop SQL injection, even ignoring case tricks?

3. Validate length, character set, and semantics

Definition. Three independent questions every input must answer: How long is it? What is it made of? Does it actually mean something legal?

How it works. These are checked in order, cheapest first:

  1. Length — bound it before you even scan it. A length check is also a memory-safety check: it is what keeps a value from overflowing a fixed buffer later.
  2. Character set — does every byte belong to the allowed alphabet (only digits, only [a-z0-9_-], etc.)?
  3. Semantics — even a well-formed string may be nonsense: port 0, an IP octet of 300, February 30th, a start date after its end date.
  input "08:75"
     |
     v
  [length ok?]  5 chars, within bound  -> pass
     |
     v
  [charset ok?] digits and one ':'     -> pass
     |
     v
  [semantics?]  minutes 75 > 59        -> REJECT

When to use / not. Always do all three. Skipping semantics is the classic mistake: the data "looks" right and is still invalid.

Pitfall. Checking character set but not range — "99999" is all digits and still an invalid port.

Knowledge check: Which of the three checks catches the string "2026-02-30", and which one catches "2026-02-3X"?

4. Canonicalise before you compare

Definition. Canonicalising means converting input to one standard form before you validate or compare it, so that inputs meaning the same thing look the same.

Plain explanation. ./foo, foo, and bar/../foo can all refer to the same file. EXAMPLE.COM and example.com are the same host. If you allow-list against foo but the attacker sends ./foo, a naive string compare fails to match — or worse, a deny-list against ../ misses ..%2f. Reduce to canonical form first, then check.

How it works internally. For paths, realpath() resolves ., .., and symlinks into one absolute path; you then verify it still sits inside your allowed directory. For case, lowercase before comparing. For encodings, decode percent-encoding once and reject anything still encoded.

When to use / not. Canonicalise whenever the same logical value has multiple textual spellings — paths, hostnames, encodings. It is unnecessary for a pure integer parse where there is one spelling.

Pitfall (double-decoding): decode, then check, then decode again before use — the attacker hides ../ as ..%252f, which survives your check and becomes ../ at use time. Decode exactly once, validate, and never decode again.

5. Fail closed

Definition. When validation cannot confirm input is good, refuse the operation. The safe default is "no."

How it works. A validator returns a clear pass/fail (or a structured error). The caller acts only on a definite pass. There is no "probably fine" branch and no silent repair.

When to use / not. Always, at security boundaries. The only exception is genuinely optional, non-security input with a provably safe default — and even then, audit every default.

Pitfall. parse_or_default() helpers that quietly substitute a value on malformed input. If the default is a privileged or surprising value, malformed input just became a feature the attacker controls.

Knowledge check: A config loader treats an unparseable max_connections line as max_connections = unlimited. Explain why that is failing open and what failing closed would do instead.

Syntax notes

A validator is just a function that returns a clear yes/no (or a structured error) and never mutates the input. The canonical shape parses, then checks range, then checks for trailing garbage.

// Returns 1 if s is a valid TCP/UDP port (1..65535), else 0.
int valid_port(const char *s) {
    if (!s || !*s) return 0;          // reject NULL and empty
    errno = 0;
    char *end;
    long n = strtol(s, &end, 10);     // parse base-10; end points past the number
    if (errno == ERANGE) return 0;    // strtol saturated: value too big for long
    if (*end != '\0') return 0;       // trailing garbage -> reject ("80x", "80 ")
    if (n < 1 || n > 65535) return 0; // out of the valid port range
    return 1;
}

Key points: check errno/ERANGE for overflow, require *end == '\0' so "80abc" is rejected, and bound the numeric range explicitly. strtol alone is not validation — strtol("80xyz") happily returns 80.

Lesson

Trust no input.

Before you act on any data, validate three things:

  • Length — is it within the bounds you allow?
  • Character set — does it contain only the characters you expect?
  • Semantics — does it actually mean something valid?

Prefer strict allow-lists (for example, only digits) over blocklists (for example, no semicolons).

When validation fails, produce a clear error and stop processing. Never silently "fix up" malicious input. That habit is exactly how injection bugs slip through.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>

/* Validate a username: 3..32 chars, allow-list [a-z0-9_-] only.
   Returns 1 if valid, 0 otherwise. Does not modify the input. */
int valid_username(const char *s) {
    if (!s) return 0;
    size_t len = strlen(s);
    if (len < 3 || len > 32) return 0;            /* length check */
    for (size_t i = 0; i < len; i++) {
        unsigned char c = (unsigned char)s[i];    /* avoid negative char UB */
        int ok = (c >= 'a' && c <= 'z') ||
                 (c >= '0' && c <= '9') ||
                 c == '_' || c == '-';            /* character-set allow-list */
        if (!ok) return 0;
    }
    return 1;
}

/* Parse s as a base-10 int within [lo, hi]. On success store it in *out
   and return 1; on any problem return 0 and leave *out untouched. */
int valid_int_in_range(const char *s, long lo, long hi, long *out) {
    if (!s || !*s) return 0;
    errno = 0;
    char *end;
    long n = strtol(s, &end, 10);                 /* parse */
    if (errno == ERANGE) return 0;                /* overflowed long */
    if (end == s || *end != '\0') return 0;       /* no digits, or trailing junk */
    if (n < lo || n > hi) return 0;               /* semantic range check */
    *out = n;                                     /* commit only on success */
    return 1;
}

int main(void) {
    const char *names[] = { "ada", "Ada", "a", "root; rm -rf /", "good_name-1" };
    for (size_t i = 0; i < sizeof names / sizeof names[0]; i++)
        printf("username %-16s -> %s\n",
               names[i], valid_username(names[i]) ? "VALID" : "reject");

    const char *ports[] = { "80", "0", "65536", "80x", "-1", "443" };
    for (size_t i = 0; i < sizeof ports / sizeof ports[0]; i++) {
        long p;
        int ok = valid_int_in_range(ports[i], 1, 65535, &p);
        if (ok) printf("port     %-16s -> VALID (%ld)\n", ports[i], p);
        else    printf("port     %-16s -> reject\n", ports[i]);
    }
    return 0;
}

What it does. Two validators. valid_username enforces length and a strict [a-z0-9_-] allow-list. valid_int_in_range shows the full safe-parse pattern: parse, detect overflow, reject trailing garbage, then range-check, and only write *out on success.

Expected output:

username ada              -> VALID
username Ada              -> reject
username a                -> reject
username root; rm -rf /   -> reject
username good_name-1      -> VALID
port     80               -> VALID (80)
port     0                -> reject
port     65536            -> reject
port     80x              -> reject
port     -1               -> reject
port     443              -> VALID (443)

Edge cases. Ada is rejected because uppercase is not on the allow-list (a deliberate strictness). root; rm -rf / is rejected on both length-neutral grounds and its illegal characters — exactly the injection payload you want stopped at the door. 65536 is one past the max port; 80x proves that trailing garbage is caught even though it starts with valid digits.

Line by line

Walkthrough of the two validators on representative inputs.

valid_username("root; rm -rf /"):

Step What happens State
if (!s) not NULL continue
strlen len = 13 within 3..32, pass length
loop i=0..3 r,o,o,t all in [a-z] ok
loop i=4 ; is not a letter/digit/_/- ok is 0
if (!ok) return 0 reject immediately function returns 0

The function short-circuits on the first bad byte — it never needs to see the rm -rf /. That early exit is the whole point of boundary validation.

valid_int_in_range("80x", 1, 65535, &p):

Step What happens Values
`if (!s !*s)`
errno = 0 clear prior errors errno = 0
strtol("80x", &end, 10) reads 8,0, stops at x n = 80, *end = 'x'
errno == ERANGE? no overflow continue
end == s? no — one or more digits consumed continue
*end != '\0'? 'x' != '\0' is true return 0

strtol succeeded in the C sense (it returned 80), but the *end check catches the trailing x. Without that check, "80x", "80 ", and "80; DROP TABLE" would all pass as 80 — a classic validation hole.

valid_int_in_range("65536", 1, 65535, &p): parses cleanly to n = 65536, *end == '\0', but n > hi so the range check returns 0. *out/p is never written, so the caller cannot accidentally use a half-validated value.

valid_int_in_range("99999999999999999999", 1, 65535, &p): strtol saturates, sets errno = ERANGE, returns LONG_MAX; the ERANGE check returns 0 before any range comparison — overflow handled explicitly, not by luck.

Common mistakes

Mistake 1 — using atoi/bare strtol as if it validates.

int port = atoi(argv[1]);   // WRONG: "80x" -> 80, "" -> 0, "9999999999" -> undefined
connect(port);

atoi has no error signal at all: it returns 0 for both "0" and "garbage", and overflow is undefined behaviour. Fix: use the strtol pattern with errno, *end, and a range check (see valid_int_in_range). Recognise it by the absence of any check on what atoi/strtol reported.

Mistake 2 — deny-list filtering instead of allow-list rejecting.

// WRONG: strip the dangerous sequence
char *p;
while ((p = strstr(path, "../"))) memmove(p, p+3, strlen(p+3)+1);

Input ....// becomes ../ after the strip — the filter created the attack. Fix: reject the whole input if it fails a canonical allow-list; never repair it. Prevent it by treating validation as a boolean gate, not a cleanup step.

Mistake 3 — validating, then re-decoding before use.

decode(buf);         // "..%252f" -> "..%2f"
if (has_dotdot(buf)) reject();   // looks clean, no "../"
decode(buf);         // "..%2f" -> "../"   <-- attack revived AFTER the check

Fix: canonicalise (decode) exactly once, validate the canonical form, and use that exact form. Recognise it whenever a decode and a check appear in the wrong order or more than once.

Mistake 4 — char sign bug in a character check.

if (isalpha(s[i])) ...   // WRONG if char is signed and s[i] > 127

Passing a negative char to the <ctype.h> functions is undefined behaviour. Fix: cast to unsigned char first, as the sample code does. Prevent it by making every byte unsigned char at the boundary.

Mistake 5 — length checked after the copy.

strcpy(dst, src);            // overflow already happened
if (strlen(dst) > MAX) ...   // too late

Fix: validate length before touching the buffer: if (strlen(src) >= sizeof dst) return 0;. This is where this lesson meets Safe string functions — validation decides, safe copies execute.

Debugging tips

Compiler warnings to enable. Build with -Wall -Wextra. A warning like "comparison is always true" on a char check usually means the sign bug from Mistake 4. "unused variable end" often means you forgot the trailing-garbage check.

Common runtime symptoms and causes.

Symptom Likely cause First thing to check
Valid input rejected Allow-list too strict, or missing a legal char Print the exact byte (%d on (unsigned char)) that failed
Invalid input accepted Missing *end check or missing range check Log parsed value and the leftover end string
Crash on huge input Overflow / missing ERANGE check Confirm errno is cleared before and read after strtol
Path escapes directory No canonicalisation before compare realpath the input and print the resolved path

Concrete steps when a validator misbehaves.

  1. Log every rejection with its reason — you want to see which of length/charset/semantics fired.
  2. Print the offending byte as a number, not a character; control characters are invisible otherwise.
  3. Fuzz it: feed the validator a large corpus of random and adversarial strings (empty string, all-max-length, embedded NUL, ../, %00, 9e999) and confirm it never crashes and never accepts junk. A fuzz test systematically explores inputs to find the ones your logic missed.
  4. Audit every *_or_default call: is the default safe if an attacker forces it?

Questions to ask when it "doesn't work": Did I check the return value and the range? Did I clear errno? Am I comparing after canonicalising? Is my char signed? Did I validate before or after the copy?

Memory safety

Validation and memory safety are the same discipline seen from two angles: a length check is an overflow guard.

Length before copy. The single most important habit:

if (strlen(src) >= sizeof dst) return 0;   // reject before it can overflow dst

This stops a buffer overflow at the boundary rather than trusting a downstream copy to be safe.

Integer overflow in the parse itself. strtol reports overflow via errno == ERANGE; atoi does not (it is undefined behaviour). Always parse with strtol/strtoul and check ERANGE. This directly feeds the next lesson, Integer overflow, where an unchecked length or count wraps around and defeats a later size calculation.

Embedded NUL bytes. strlen stops at the first \0, so an attacker can smuggle bytes past a length check by embedding a NUL ("safe\0; rm -rf /"). If your input can contain NUL (network data, file reads), validate against a known byte count, not strlen.

Signed char UB. Bytes from input are arbitrary; on platforms where char is signed, a byte > 127 becomes negative and passing it to <ctype.h> functions is undefined behaviour. Cast to unsigned char at the boundary.

Do not write output on failure. valid_int_in_range writes *out only on success. A validator that scribbles a partial result into an out-parameter on failure invites the caller to use uninitialised or half-formed data — an ownership/lifetime hazard.

Defensive posture (security). Validate at the boundary (defence in depth), apply least privilege to whatever the validated data controls, and prefer safe APIs (strtol over atoi, snprintf over sprintf, parameterised queries over string-built SQL). Where a validator is the only thing standing between input and a shell/SQL/path, treat it as security-critical code and test it adversarially.

Real-world uses

Where it shows up. Input validation is everywhere data enters software: web frameworks validating form fields and route parameters, firewalls checking packet headers, config loaders parsing key=value lines, DNS servers rejecting malformed names, SQL layers binding parameters, and command-line tools parsing argv. A single missed validator has caused real, catastrophic breaches (path traversal in web servers, SSID/username injection in embedded devices).

A concrete case. A firewall ACL rule like allow 192.168.1.0/24 is only safe because the loader strictly parses each IPv4 octet (0..255), the prefix length (0..32), and rejects anything else. Loosely parsed, 192.168.1.300 or 192.168.1.1; DROP would either misconfigure the firewall or inject into whatever consumes the config.

Professional best practices.

Habit Beginner Advanced
Where to validate One validator per input, at the boundary Centralised validation layer; downstream code declares its assumptions
Allow vs deny Always allow-list Allow-list plus canonicalisation plus context-aware output encoding
Errors Return 0/1, print a clear message Return a structured error (bitmask/enum) reporting every failed rule at once
Testing Try a few good and bad inputs by hand Fuzz the validator; property tests; regression corpus of past attacks
Logging Log that something was rejected Log the reason and rate-limit to avoid log flooding
Naming valid_username, valid_port Names encode the guarantee; validators are pure, side-effect-free, testable

Across both levels: validate early, reject rather than repair, fail closed, and never trust that "someone upstream already checked."

Practice tasks

Beginner 1 — Port validator. Implement int valid_port(const char *s) returning 1 for a valid TCP/UDP port (1..65535) and 0 otherwise. Requirements: reject NULL, empty, leading +/whitespace, trailing garbage ("80x"), and out-of-range values. Example: "443" -> 1, "0" -> 0, "65536" -> 0, "80 " -> 0. Hint: use the strtol + errno + *end + range pattern. Concepts: length/charset/semantics, fail closed.

Beginner 2 — Username allow-list. Implement int valid_username(const char *s) accepting only lowercase letters, digits, _, and -, length 3..32. Requirements: reject uppercase, spaces, and any punctuation outside the allow-list; do not modify the input. Example: "ada_9" -> 1, "Ada" -> 0, "a" -> 0. Hint: check length first, then loop with an unsigned char cast. Concepts: allow-list, character-set validation.

Intermediate 1 — Strict HH:MM time. Implement int valid_time(const char *s) that accepts exactly five characters HH:MM where hours are 00..23 and minutes 00..59. Requirements: reject "8:05" (wrong length), "24:00", "12:60", "1a:00". Example: "23:59" -> 1, "24:00" -> 0. Hint: this needs all three checks — fixed length, digits in the right positions, and semantic range on each field. Concepts: length + charset + semantics together.

Intermediate 2 — Structured password report. Implement unsigned password_check(const char *pw) that returns a bitmask of failed rules (0 means all passed): bit 0 = length < 8, bit 1 = no lowercase, bit 2 = no uppercase, bit 3 = no digit. Requirements: report every failing rule at once, not just the first. Example: "abc" returns bits 0|2|3 set. Hint: scan once, set flags, combine with |. Concepts: structured validation errors, fail closed with actionable feedback.

Challenge — Safe path check. Implement int safe_relative_path(const char *p) that returns 1 only if p is a relative path that stays inside the current directory. Requirements: reject absolute paths (leading /), any component equal to .., embedded NUL considerations, and empty input; allow ordinary nested paths like data/report.txt. Bonus: describe (in a comment) how you would additionally use realpath to confirm the resolved path is still inside an allowed base directory. Constraints: do not modify p; validate before any file operation. Hint: canonicalise your reasoning about .. — splitting on / and checking each component beats a naive strstr(".."). Concepts: canonicalisation, allow-list over deny-list, fail closed, memory safety.

Summary

Input validation is the guard at your program's front door: it inspects every piece of untrusted data at the trust boundary before any deeper code touches it.

Core ideas. Validate three things — length, character set, and semantics — in that order. Prefer allow-lists (say what is valid) over deny-lists (chase what is bad). Canonicalise input to one form before comparing it. Fail closed: refuse anything you cannot confirm, and never silently repair malicious input.

Most important syntax. The safe integer parse: strtol with errno = 0 before, then check ERANGE, require *end == '\0' to reject trailing garbage, then range-check, and only write the output on success. Never use atoi for untrusted input.

Common mistakes. Trusting atoi/strtol without checking *end and range; filtering instead of rejecting; decoding after validating; the signed-char UB in <ctype.h> calls; and checking length after the copy instead of before.

What to remember. A length check is also a memory-safety check — it is the bridge from Safe string functions into the next lesson, Integer overflow. Validate early, reject rather than repair, fail closed, and put the guard at the boundary so everything behind it can finally relax.

Practice with these exercises