Chapter 8 — Programming II
Lesson map
Lesson 08 published. Map, example, EX015.
flowchart TD
N0["Learning outcomes and connection"]
N1["8.1 Input is a security boundary"]
N2["8.2 Validation rules"]
N3["8.4 Testing"]
N4["8.6 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5
N5 --> EX["Worked example"]
EX --> Q["Exercise EX015"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["8.1 Input is a security boundary"]
N2["8.2 Validation rules"]
N3["8.4 Testing"]
N4["8.6 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> Q["EX015"]
[Learning outcomes and connection]
|
v
[8.1 Input is a security boundary]
|
v
[8.2 Validation rules]
|
v
[8.4 Testing]
|
v
[8.6 Practical activity]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX015]
Learning outcomes and connection
After this chapter you can (1) validate untrusted input with explicit rules and reject what does not conform; (2) handle errors so that a program fails safely and visibly; (3) write unit tests, including tests for boundary and malformed cases; and (4) explain why parsing is a security boundary. It builds on Chapter 3 and Chapter 7 and prepares Chapter 9 (data) and Chapter 27 (verification).
8.1 Input is a security boundary
Every program that reads data from outside — a file, a network message, an operator's entry — makes assumptions about it. When the assumptions are wrong, programs crash, mis-compute or, worse, do something an attacker chose. Software weakness catalogues are dominated by input-handling errors: injection, out-of-bounds reads and writes, improper input validation (CWE Top 25). The defensive pattern is to parse, don't guess: define exactly what valid input looks like, convert raw text into typed values in one place, and reject anything else with a clear error.
8.2 Validation rules
For a setpoint sent to a pumping station, valid input might be: a number; finite (not "NaN" or infinity); within engineering limits (0.5–4.5 m); changing by no more than 0.5 m from the current value; from an authorised source. Each rule should be explicit, tested and logged when it rejects. Allowlisting (accept only known-good forms) is stronger than denylisting (reject known-bad forms) because attackers find forms you did not think of. Python's float("nan") succeeds, and nan < 0.5 and nan > 4.5 are both false — so a range check written as "reject if below or above" quietly accepts NaN. The robust check is "accept only if math.isfinite(x) and 0.5 <= x <= 4.5".
8.3 Errors and failing safely
When validation fails, the program must do something defined: reject the command and keep the last good value, raise an alarm, log the reason. It must not continue with a default that happens to be dangerous (a setpoint of zero), and it must not swallow the error silently (except: pass). In control contexts, "safe" is a process decision — a valve may need to fail closed for chlorine but open for cooling water — so the programmer implements a failure state that the process owner has specified, rather than inventing one.
8.4 Testing
A unit test checks one function against known cases. Good test sets include typical values, boundaries (exactly 0.5 and 4.5), just outside boundaries (0.49, 4.51), malformed inputs (empty string, "abc", "1e309", "nan"), and state-dependent cases (a valid value that changes too fast). Python's built-in unittest module or plain assert statements are sufficient at this level. A test that has never failed has never been shown to work: run each new test once against a deliberately broken version.
For CW's setpoint validator, the version to fail against is v1: its "nan" test returns True and its "abc" test raises, so two of the seven tests fail before the fix and none after it.
8.5 Worked example: CW setpoint validator (synthetic)
This case is synthetic. Calder Water's operator interface sends tank-level setpoints as text. Version 1 of the validator:
def accept_v1(text, current):
x = float(text)
if x < 0.5 or x > 4.5: return False
if abs(x - current) > 0.5: return False
return True
accept_v1("nan", 3.0) returns True: both comparisons with NaN are false, and abs(nan - 3.0) > 0.5 is also false. accept_v1("abc", 3.0) raises an exception that, depending on the caller, may crash the interface. Version 2:
import math
def accept_v2(text, current):
try:
x = float(text)
except ValueError:
return False
return math.isfinite(x) and 0.5 <= x <= 4.5 and abs(x - current) <= 0.5
Version 2 states what is acceptable rather than what is not. Tests: ("3.2", 3.0) → True; ("4.5", 4.2) → True; ("4.51", 4.2) → False; ("nan", 3.0) → False; ("inf", 3.0) → False; ("abc", 3.0) → False; ("2.0", 3.0) → False (step too large).
The try block turns a crash into a rejection, and math.isfinite removes NaN and infinity before any comparison. What v2 still does not do is say why it rejected: the operator interface should log which rule failed.
8.6 Practical activity
Implement both versions and the seven tests above. Run the tests against v1 and record which fail; then against v2. Add two tests of your own that you think v2 might still get wrong (hint: whitespace, very long strings, a comma decimal separator) and decide whether the behaviour is acceptable.
Against v1, expect the "nan" test to fail (it returns True) and "abc" to raise instead of returning False. Against v2 all seven should pass. For your own tests, " 3.2 " with spaces is accepted by Python's float, which is probably fine; "3,2" is rejected, which may surprise an operator using a comma decimal. Decide whether that rejection should produce a clear message rather than silent refusal.
8.7 Common errors and limits
- Range checks that pass NaN.
- Catching every exception and continuing.
- Validation in the user interface only, not where the command is executed.
- Tests that only cover typical values.
- Believing validation makes a channel authentic: a valid-looking forged command still passes (Chapter 17 adds authentication).
At CW the third error is the dangerous one: if only the operator interface checks setpoints, any other client that can reach the PLC over Modbus bypasses validation entirely, as Lab 4's field-zone conduit shows. The fifth error links to Lab 8: a well-formed 2.0 m setpoint passes v2 whether an operator or an intruder sent it.
Exercises — chapter 8
EX015 — Break v1. Objective: find inputs that defeat a denylist check. Inputs: accept_v1. Steps: find three distinct inputs for which v1 gives an unsafe or undefined result, and explain each. Expected evidence: inputs and explanations. Rubric: 3 = NaN accepted; non-numeric text (or an empty string) raises an unhandled exception; and a third distinct case, such as a NaN current value, which makes the step check pass for any in-range setpoint; 2 = two; 1 = one; 0 = none.
EX016 — Specify the failure state. Objective: connect code to process safety. Inputs: a rejected setpoint at a chlorine-dosing pump and at a raw-water inlet valve. Steps: for each, state what the program should do on rejection, who should decide that, and what must be logged. Expected evidence: two short specifications. Rubric: 3 = keep last good value plus alarm, process owner decides, log fields named (time, source, raw input, rule failed); 2 = partial; 1 = generic; 0 = none.
Chapter synthesis
Parsing is a security boundary. Define valid input precisely, accept only that, fail into a state the process owner chose, log every rejection and test boundaries and malformed cases. Validation catches malformed input; it does not prove that well-formed input came from someone authorised.
Continue studying — chapter 08 of 40
← Chapter 07 · Course chapter index · Chapter 09 →
Continue to the next authored lesson.