JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Defensive Coding and Untrusted Input

The last four guides taught you how C bites: undefined behavior, overflow, out-of-bounds, banned functions like gets(). This one is the discipline that keeps the bites from happening — drawing a line around the data you do not control, validating it before it can hurt you, and checking every call so a failure surfaces loudly instead of festering into a security hole.

From dodging UB to designing against it

Up to now this rung has been a tour of hazards. You met the abstract machine and learned that undefined behavior is not "whatever the compiler feels like" but a license for the optimizer to assume the bad thing never happens — which is why a bug can vanish at -O0 and corrupt silently at -O2. You saw signed overflow, out-of-bounds reads, uninitialized reads, and the classic buffer overflow that made gets() unusable. Each guide answered "what goes wrong." This last guide answers a different question: how do you write C so the wrong thing has no chance to start?

The shift is from reaction to design. A defensive program does not trust its inputs to be well-behaved, does not assume a call succeeded, and does not let one corrupted value wander deep into the system before anyone notices. Defensive coding is the name for this posture: you write each function as if a hostile or careless caller is on the other side, because in a long-lived system one eventually is. The cost is a little extra code — a check here, a bound there. The payoff is that the failures you saw in the last four guides become impossible-by-construction rather than caught-by-luck.

The trust boundary: drawing the line

The single most useful idea in secure coding is the trust boundary. Picture your program as a walled house. Inside the walls live values you created and control: a length you just computed, a constant, a string you built yourself. Outside are values you did not create: bytes from read(), an argument in argv, a line typed at a prompt, a field in a network packet, the contents of an environment variable, a file someone else wrote. A trust boundary is the wall itself — the exact place where outside data first enters your code. Everything outside is, by default, untrusted: possibly empty, possibly enormous, possibly crafted by an attacker to make you overflow a buffer or index past an array.

The discipline is simple to state and hard to obey: validate untrusted data once, at the boundary, before it touches anything else. Do not sprinkle half-checks through the body of your program, hoping each user of the value remembers to be careful — that is exactly how an off-by-one or an unchecked length slips through. Instead, the moment data crosses the wall, you stop and ask: is the length within the buffer I have? Is this number in the range I expect? Does this string actually fit, and is it null-terminated where I think? If it passes, it becomes trusted from that point on and the inner code can rely on it. If it fails, you reject it right there, before it can cause out-of-bounds access or worse.

Picture it as one gate in the wall. On the untrusted side stand argv, the bytes from read(), environment variables, packet fields, file contents, and whatever was typed at stdin. All of it funnels through a single validation point at the boundary, where bad input is rejected. Only what passes reaches the trusted side, and from there the inner functions are allowed to assume the data is sane — its length fits, its range is expected, its string is terminated. Drawing this one gate, and refusing to let any path sneak around it, is most of secure coding in C.

Validating input without lying to yourself

Concretely, validation in C is mostly about lengths and ranges, because that is where the abstract machine punishes you. Suppose you read a length n from the network and then copy n bytes into a 256-byte buffer. The naive code copies n bytes; the defensive code first asks whether n is at most the room you have. This is the live form of the off-by-one and overflow lessons: a single missing bound is the gap an attacker drives a truck through. And remember signed overflow and unsigned wrap from earlier — a length compared after an arithmetic step can wrap to a tiny value that passes your check and then overflows anyway, so validate the raw input before you do arithmetic on it.

  1. Identify the source. Name where the data came from: argv, read(), a file, an env var. If it came from outside your process, it is untrusted — no exceptions.
  2. Check the length first, against the real size of your destination. If you have a 256-byte buffer, the question is always "is n <= 256?" (or 255, leaving room for the null terminator) — and answer it before copying a single byte.
  3. Check the range and shape. Is the number within the values you can handle? Does a parsed string contain only the characters you allow? Reject anything you do not understand rather than guessing.
  4. Use bounded library calls. Prefer functions that take a size argument and stop at it — read(fd, buf, sizeof buf), snprintf() over sprintf() — so the size of your buffer, not the attacker's input, sets the limit.
  5. Decide what failure means here, and act on it. Return an error code, log it, or refuse the request — but never fall through and use data that failed validation.

There is a subtler validation hazard worth a sentence: a dangerous string function like strcpy() or strcat() trusts that its source is null-terminated and that the destination is big enough — two assumptions that untrusted input loves to break. A network buffer with no null byte makes strlen() walk off the end. So part of validating at the boundary is establishing the invariants the inner code assumes: if your code later calls a string function, the boundary is where you guarantee the string is actually terminated within bounds.

Check every call, and copy what you must keep

Validation guards the data coming in; checking return values guards the operations going out. The rule check every call is exactly what it sounds like, and it is the habit that separates safe systems code from the rest. In C there are no exceptions to catch you — a failed malloc() returns a null pointer, a failed open() returns -1 and sets errno, a short read() returns fewer bytes than you asked for, and if you ignore any of these you sail onward using a value that is a lie. Dereferencing the null from malloc() is a segfault at best; using the -1 from open() as a file descriptor corrupts whatever fd 0xFFFFFFFF happens to alias. The first four guides showed UB you cause; unchecked calls are UB the environment hands you.

char *buf = malloc(n);
if (buf == NULL) {        /* never skip this branch */
    perror("malloc");
    return -1;            /* propagate, do not press on */
}
/* only here is buf safe to dereference */
The canonical shape: check, handle, propagate — every allocation and every syscall.

One last move closes a quieter hole: defensive copying. When you receive a pointer to data you do not own — a buffer the caller still holds, a string from a shared structure — and you need to keep it around or hand it on, do not store the borrowed pointer; copy the bytes into storage you control. The danger of trusting a borrowed pointer is that the owner may free it or overwrite it behind your back, turning your saved pointer into a dangling pointer and your next read into a use-after-free. A defensive copy severs that fate: now the lifetime is yours, the bytes cannot change under you, and an attacker who mutates the original after your check cannot bypass it — the time-of-check-to-time-of-use trap.

Fail fast, and the honest case for Rust

When a check does fail, you face a choice the fail-fast versus graceful-degradation guide framed earlier. For an expected problem — a user typed a bad filename — you handle it gracefully: return an error, print a message, ask again. For an impossible state that means your own logic is broken — a length that came out negative, a pointer that should never be null — the safest move is to fail fast: stop immediately rather than limp onward corrupting more state. An assertion like assert(n <= capacity) is the tool for that second case: it documents an invariant and, if reality violates it, aborts loudly with a core dump you can read, instead of letting a corrupted value spread into a symptom you will debug an hour later and a thousand lines away.

Here is the honest reckoning that ends the rung. Everything above is discipline — and discipline is exactly what humans forget at 3am under deadline. You can validate every boundary and check every call perfectly for years, then miss one length on one Tuesday, and that single lapse is a memory-corruption bug. This is the structural reason C is hard, and it is the contrast that motivates Rust: in safe Rust the compiler enforces at compile time much of what you have been enforcing by hand here — bounds are checked, a value cannot be used after its owner frees it, an uninitialized read will not compile. Whole classes of the bugs in this rung become impossible in safe code, without a garbage collector.

But Rust is not magic, and selling it as the end of all bugs would betray everything this rung stands for. The borrow checker has a real learning curve and will reject correct-looking code until you internalize ownership; logic bugs, deadlocks, and integer-overflow-as-wrong-answer all survive the move; and an unsafe block still lets you do the dangerous things by hand when you must, with the same responsibilities you carry in C. The honest framing is not "C bad, Rust good" but a different set of tradeoffs: C gives you total control and total responsibility, and the defensive discipline of this guide is how you carry that responsibility well. Rust moves much of that responsibility into the compiler — at the cost of a stricter language you must learn to satisfy. Master the discipline first; it makes you safer in C today and a better Rust programmer tomorrow.