Lesson 10 / 15
Input Validation and Sanitization
Narrowing a client input to the valid set with filter_var(), and measuring that validation and sanitization are separate steps and that validation does not take the power to change the value away from the client.
Published Updated
In this lesson
- Validates an input with filter_var() and range options, and shows that an invalid format returns false
- Separates validation from sanitization and measures that sanitization silently turns a malformed input into another value
- Explains with the party measurement that validation does not take the result away from the client and only narrows it to the valid set
Contents
In The HTTP Client lesson, a timeout exception bounded the result when an external service’s response was slow, but the price still stayed in the third party’s hands. The security topic turns the same question to the data that reaches the application directly: a value that arrives as a query parameter, a form field or a cookie is something the client sent. Is this input valid, and if it is valid, who can change the result?
SE1. Throughout this topic, the client input is a string constant; no real request is built. Invalid input is shown with neutral format errors ('4x', '', 'alice@'), not with strings of special characters. The code that runs is on the defending side. In the advanced course of the Rust curriculum, The Rust Ecosystem and Advanced Topics, we measured where a guarantee ends at a check; here the question is not the check itself but who is left holding the result.
Validation: The Valid Set
Input validation decides whether an incoming value has the expected format and falls in the expected range, and rejects the value if it does not. In PHP the built-in tool for this is filter_var(): its second argument is a filter constant and its third is an options array. FILTER_VALIDATE_INT checks the integer format, and the min_range and max_range options check the range; on a matching input it returns the integer, and on a non-matching one it returns false. FILTER_VALIDATE_EMAIL tests the email format with the same contract.
Two small validators are used throughout the lesson. The underscored constant names follow PHP convention; the file is only written to disk, and the measurement files pull it in.
<?php // input.php — quantity and email validators declare(strict_types=1); const QUANTITY_OPTIONS = ['options' => ['min_range' => 1, 'max_range' => 10]]; function validateQuantity(string $input): int|false { return filter_var($input, FILTER_VALIDATE_INT, QUANTITY_OPTIONS); } function validateEmail(string $input): string|false { return filter_var($input, FILTER_VALIDATE_EMAIL); }
An order quantity must be between 1 and 10. The measurement below passes four quantity inputs and two email inputs through the validators, then tries every integer from 0 to 12 and counts how many are accepted.
<?php // validate.php — valid and invalid formats declare(strict_types=1); require 'input.php'; foreach (['4', '4x', '', '11'] as $input) { echo 'quantity ', var_export($input, true), ' → ', var_export(validateQuantity($input), true), "\n"; } foreach (['[email protected]', 'alice@'] as $input) { echo 'email ', var_export($input, true), ' → ', var_export(validateEmail($input), true), "\n"; } $accepted = array_filter(range(0, 12), fn(int $n) => validateQuantity((string) $n) !== false); echo count($accepted), ' values accepted: from ', min($accepted), ' to ', max($accepted), "\n";
quantity '4' → 4 quantity '4x' → false quantity '' → false quantity '11' → false email '[email protected]' → '[email protected]' email 'alice@' → false 10 values accepted: from 1 to 10
A valid format passes by converting the value to an integer: the text '4' becomes 4. '4x' and the empty string are format errors, and '11' is valid in format but outside the range; all three return false. This is why the return type is int|false, and the caller has to check for false explicitly; if false is used as a number, it behaves like zero. The count shows the set the validator defines: of 13 values, only the ten between 1 and 10 pass.
Checking the Return Value
The int|false return has a trap: a valid result can be 0, and 0 counts as false in PHP. The measurement below checks a validator whose lower bound is zero in two ways: with a loose comparison ($result == false) and with a strict comparison ($result === false).
<?php // zero.php — when zero is a valid value declare(strict_types=1); function validateStock(string $input): int|false { return filter_var($input, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 10]]); } foreach (['0', '5', '4x'] as $input) { $result = validateStock($input); echo var_export($input, true), ' → ', var_export($result, true), ' · loose reject ', var_export($result == false, true), ' · strict reject ', var_export($result === false, true), "\n"; }
'0' → 0 · loose reject true · strict reject false '5' → 5 · loose reject false · strict reject false '4x' → false · loose reject true · strict reject true
The loose comparison rejects the valid input '0' as well: 0 == false is true. The strict comparison catches only the real false, that is, the format error '4x'. The validator’s contract is therefore read with === false; if zero is in the valid set, a loose check silently loses a valid value. This time the fault is on the function side, not the security side, but it comes from the same root: if the result of validation is read wrongly, validation itself proves nothing.
Party: Validation Does Not Change the Owner
The course’s question now goes to this result. The core file is party.php, given in the same form in every lesson; EXTERNAL_PARTIES holds the three parties outside the code in decision order, probe() computes the result first without deviation and then separately with each deviation, and party() picks the first party that changed it, or the server if none could. The order is the width of the attack surface: the client is everyone who sends a request, the third party is only the parties the application connects to, and the environment is only in the hands of whoever deploys.
<?php // party.php — who can change this result? declare(strict_types=1); // The parties outside the code, in decision order: every request, every external call, every deployment. const EXTERNAL_PARTIES = ['client', 'third party', 'environment']; /** Computes the result once without deviation, then once with each external party's deviation. */ function probe(callable $result, array $deviations): array { $baseline = $result([]); $changed = []; foreach (EXTERNAL_PARTIES as $party) { if (array_key_exists($party, $deviations)) { $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline; } } return $changed; } /** The first external party that can change the result; the server if none can. */ function party(array $changed): string { foreach (EXTERNAL_PARTIES as $p) { if ($changed[$p] ?? false) return $p; } return 'server'; } function partyReport(string $name, callable $result, array $deviations): string { $changed = probe($result, $deviations); $lines = []; foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change'); $owner = party($changed); echo "$name → $owner\n ", implode(' · ', $lines), "\n"; return $owner; }
The measured result is the validated quantity. In the undeviated request the client sends '3'; the client’s deviation is another valid number, '7'.
<?php // quantity.php — the party of the validated quantity declare(strict_types=1); require 'party.php'; require 'input.php'; partyReport('quantity (validated)', fn(array $d) => validateQuantity($d['client'] ?? '3'), ['client' => '7']);
quantity (validated) → client
client changed
The result belongs to the client. Validation did not reject '7', because '7' is inside the valid set; the result moved from 3 to 7. Validation does not prevent the client from changing the value. What it does is narrow the values the client can change to the ten integers in the count: '4x', '' and '11' turn the result into false and never reach the rest of the application. The narrowing is a gain, but the owner has not changed; the quantity is still set by whoever sends the request.
The Bounding Measurement
Next to validation there is a second step that is often confused with it. Sanitization does not reject the input, it changes it: FILTER_SANITIZE_NUMBER_INT deletes every character except digits, plus and minus, and accepts what remains. The two steps are separate: validation asks “is this value valid?” and can say no; sanitization cannot say no. The same three inputs pass through both, and the sanitized value is also given to the validator.
<?php // sanitize.php — sanitization and validation side by side declare(strict_types=1); require 'input.php'; foreach (['4', '4x', '1x0'] as $input) { $sanitized = filter_var($input, FILTER_SANITIZE_NUMBER_INT); echo var_export($input, true), ' → sanitization ', var_export($sanitized, true), ' · validation ', var_export(validateQuantity($input), true), ' · sanitize then validate ', var_export(validateQuantity($sanitized), true), "\n"; }
'4' → sanitization '4' · validation 4 · sanitize then validate 4 '4x' → sanitization '4' · validation false · sanitize then validate 4 '1x0' → sanitization '10' · validation false · sanitize then validate 10
The result points in the expected direction, and it is unsettling. Validation rejects '4x' and '1x0'; sanitization accepts both, and by changing them: '4x' silently becomes 4, and '1x0' silently becomes 10. The sanitized value passes the validator, because sanitization has erased the malformation. The information that the input was malformed is lost at this point: the application receives an order of four items, while the user typed 4x. The bounding result is this: sanitization does not take the place of validation. A malformed input is rejected first by validation; sanitization is used where the value cannot be rejected and only has to be brought into a safe form.
Summary
- The measured class of this lesson is the client: the client changed the validated quantity from
'3'to'7'; validation did not prevent it. filter_var()withFILTER_VALIDATE_INTand range options narrows the input to the valid set: of 13 values only ten passed, and an invalid format returnsfalse.- Validation rejects, sanitization changes and accepts; the two are separate steps and neither replaces the other.
- Sanitization silently turns a malformed input into another value; an input that should have been rejected enters the result as a value.
- A defense does not always change the owner: validation does not move the result to the server, it only narrows what the client can change.
Next Step
A validated value, as long as it stays in the valid set, is still a value chosen by the client. So if this value goes into a query text, who determines the text itself? The Preventing SQL Injection lesson measures that a prepared statement takes the query text from the client and gives it to the server.
To keep your progress and take notes, Log in
My notes
Log in to take notes.