Build1 publisher3 min readPublished
isinstance(amount, (int, float)) is not a number check: NaN walks through a withdrawal guard
A dev.to walkthrough sends {"amount": NaN} to an ordinary Flask endpoint and drains it. Python's json parser accepts the constant, and three plausible guards all let it through.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The post's example Flask endpoint POST /withdraw keeps STATE = {"balance": 1000}, parses the body with json.loads (returning "Malformed JSON", 400 on ValueError), rejects non-dict payloads, and then applies three guards to data.get("amount"): isinstance(amount, (int, float)), amount <= 0, and amount > STATE["balance"], before doing STATE["balance"] -= amount.
- The stated intention of that validation code is to support the amount field as numeric, allow withdrawal only for a positive amount, and reject a withdrawal if the amount exceeds the balance.
- Sanity checks against the running Flask dev server: amount 100 returns "Success! New balance: 900"; amount 0 returns "0 is too small"; amount 9999999 returns "9999999 is greater than your balance"; amount 900 returns "Success! New balance: 0"; a second amount 900 returns "900 is greater than your balance".
- curl 0:5000/withdraw --json '{"amount": NaN}' returns "Success! New balance: nan".
- The native json parser in Python accepts the NaN constant, converting it to float("nan") and allowing arithmetic operations on it.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A walkthrough published on dev.to takes an ordinary Flask withdrawal endpoint, wraps it in three checks that look complete, and then empties it with a single request body of `{"amount": NaN}` [1][4]. The guards test type and magnitude, and none of them asks whether the value is a usable number, which is exactly the gap Python's own JSON parser hands them [5].
The endpoint holds a balance of 1000 in a module-level dict, parses the body with `json.loads`, rejects anything that is not a JSON object, and then applies three tests: `isinstance(amount, (int, float))`, `amount <= 0`, and `amount > STATE["balance"]` [1]. The stated intent is the obvious one: accept numeric amounts, allow only positive withdrawals, refuse anything larger than the balance [2]. On the happy path it behaves. 100 leaves 900, 0 comes back as "too small", 9999999 is rejected as greater than the balance, 900 takes the balance to 0, and a repeat 900 is refused [3].
Then the post sends `{"amount": NaN}` and gets "Success! New balance: nan" [4]. Python's native `json` parser accepts NaN, described in the post as one of the non-standard JSON constants, and converts it to `float("nan")` [5][8]. That object is a float, so the `isinstance` guard admits it [14]. The remaining two guards are comparisons, and since the request succeeded, neither `amount <= 0` nor `amount > balance` was true of the value [15].
Arithmetic does the rest. `1000 - nan`, `1000 + nan`, `nan * 0` and `nan - nan` all evaluate to nan [6]. The balance is now nan, and every subsequent withdrawal succeeds: the post follows the NaN request with 100 and then 1000, both returning "Success! New balance: nan" [7]. That is 1100 withdrawn against a starting balance of 1000 with no guard tripped [16].
Persisting the value changes the failure mode rather than containing it. In the SQLite variant the balance is a REAL column seeded at 1000; a withdrawal of 123 writes 877.0, and the NaN withdrawal writes NULL [9][10]. The author's summary is that the balance is broken from then on and cannot be operated on [11]. Postgres, according to the same post, stores NaN as an actual numeric value, corrupting the balance in a different way [12].
The transferable point is the shape of the check rather than the specific constant. `isinstance` answers a question about the object's type, and float("nan") is a float [14]. Because nan propagates through arithmetic [6] and does not compare true against a threshold [15], no combination of a type test and relative magnitude tests can exclude it; the guard has to assert finiteness before the value reaches the subtraction [17]. Two things worth an audit in your own services: every path where a numeric field from `json.loads` is subtracted, compared or written to a column, and what your specific database does with a non-finite float, since SQLite and Postgres in this post diverge [10][12]. Note also that the source material available here cuts off mid-command after the SQLite NULL, so the Postgres behaviour is asserted by the author rather than shown [12].