Secure Coding in C · intermediate · ~10 min
- 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
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:
argv)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.
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.
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.
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
SELECTfail to stop SQL injection, even ignoring case tricks?
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:
[a-z0-9_-], etc.)?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"?
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.
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_connectionsline asmax_connections = unlimited. Explain why that is failing open and what failing closed would do instead.
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.
Trust no input.
Before you act on any data, validate three things:
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.
#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.
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.
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.
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.
../, %00, 9e999) and confirm it never crashes and never accepts junk. A fuzz test systematically explores inputs to find the ones your logic missed.*_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?
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.
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."
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.
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.