skip to content

A proxy compares a numeric field against a limit and forwards the body: how can the backend see a different number?

level: seniorimportance: should knowfreq 44%

answer

  1. the wire carries decimal text
  2. each decoder picks a machine type
  3. binary64 is exact to 2^53
  4. rounding below the comparison threshold
  5. check and use on different representations

basics

~20 s

A JSON number is decimal text of unbounded precision, but each decoder maps it onto a concrete type: a binary floating-point value, a fixed-width integer, or an arbitrary-precision decimal. Different targets round, overflow and coerce differently, so one payload yields two numbers.

solid answer

~50 s

The number on the wire is text; the number a program compares is whatever type the decoder chose. A decoder targeting IEEE 754 binary64 represents integers exactly only up to 2^53, and rounds anything finer than the spacing at that magnitude — `10000.000000000000001` becomes exactly `10000`. A decoder targeting an arbitrary-precision decimal keeps every digit and reads the same text as greater than `10000`. So a proxy checking `amount <= 10000` as a float approves a body the backend then treats as over the limit. Coercion widens the gap further: one side may accept a quoted `"10000"`, a leading `+`, a leading zero or an exponent form where the other refuses, and out-of-range values may saturate, wrap, become an infinity, or raise an error. The remedy is to compare on the same representation the consumer uses, or to constrain numbers at the boundary.

code

json · 4 lines
json
{
  "amount": 10000.000000000000001,
  "id": 9007199254740993
}

go deeper

for a junior

Remember that a number in a text encoding is written as digits with no declared width, and the decoder decides which machine type holds it.

for a middle

Explain the three target types and what each does with a value it cannot hold exactly: round, wrap, saturate, raise, or keep every digit.

for a senior

Produce a concrete bypass: a value that rounds to the limit on one side and exceeds it on the other, plus the boundary constraint you would add to stop it.

for a principal

Decide what the wire contract should promise about numbers at all — scale, range and spelling — so that no consumer's type choice can become a security-relevant difference.

## Text on the wire, a type in memory A self-describing text encoding writes numbers as decimal text and says almost nothing about range or precision: JSON's grammar permits an optional sign, digits, an optional fraction and an optional exponent, with no limit on how many digits appear. The data model does not name a machine type. Every decoder therefore chooses one, and that choice is where two components stop agreeing. The three common targets behave differently on the same input: | Target type | Exact for | Behaviour on excess | |---|---|---| | IEEE 754 binary64 | integers up to 2^53; values on the binary grid | rounds to the nearest representable value; becomes an infinity past the range | | 64-bit two's-complement integer | integers in the signed 64-bit range | rejects, truncates a fraction, or wraps, depending on the decoder | | Arbitrary-precision decimal | every digit written | no loss; cost is memory and slower arithmetic | ## The arithmetic that makes the bypass concrete Binary64 carries a 53-bit significand. Every integer up to **2^53 = 9007199254740992** is representable exactly; above that, only every second integer is, so `9007199254740993` decodes to `9007199254740992` — the nearest representable neighbour. An identifier compared for equality after that conversion matches a value the client never sent. The same gap appears with fractions, and it is the cleaner attack. Near `10000` the exponent is fixed and adjacent binary64 values are 2^-39 apart, roughly 1.8 x 10^-12. The text `10000.000000000000001` differs from `10000` by 10^-15, far below that spacing, so it decodes to **exactly 10000**. A proxy evaluating `amount <= 10000` over a binary64 value approves it. A consumer that decoded the same text into an arbitrary-precision decimal reads a value strictly greater than `10000` and, if it enforces the same rule, would have refused it — but it is not the component enforcing the rule. Reverse the roles and the divergence still pays: a precise checker and a rounding consumer let a value that failed the check be applied at a rounded-down amount, which is the shape behind quantity and price mismatches. ## Coercion, the second axis Beyond precision, decoders disagree about what counts as a number at all: - **Quoted numerics.** A lenient binder accepts `"10000"` for a numeric field and coerces it; a strict one rejects the document. A checker that skips non-numeric values entirely then never evaluates the rule at all — the field is present, unchecked, and used downstream. - **Spelling.** A leading `+`, a leading zero, an exponent form such as `1e4`, or surrounding whitespace are accepted by some decoders as extensions and refused by others, so the two sides may not even agree that a value exists. - **Special values.** Text such as an infinity or a not-a-number token is outside the JSON grammar but accepted by lenient decoders. A comparison against a limit is **false** for a not-a-number value, so a rule written as "reject when above the limit" silently passes it while "accept only when at or below the limit" refuses it. The polarity of the comparison decides the outcome. - **Out-of-range.** One decoder raises an error, another saturates to the type's maximum, a third yields an infinity. Each is a different number for the check to compare. ## Defences, in the order to propose them 1. **Compare on the consumer's representation.** If the consumer uses an arbitrary-precision decimal, the checker must too. Sharing a representation is more reliable than sharing an implementation. 2. **Constrain numbers at the boundary.** Reject fractional digits beyond the scale the domain actually uses, reject magnitudes outside the consumer's exact range, reject exponent and quoted forms. This shrinks the set of documents any two decoders can read differently. 3. **Carry money and identifiers as strings with an explicit scale**, decoded deliberately rather than by the generic number path. This removes the decoder's type choice from the security argument entirely. 4. **Test differentially.** Feed both decoders values at the boundaries — 2^53 and just above, the limit plus a digit beyond binary64 spacing, the signed 64-bit extremes — and compare what each produces. ## What interviewers are listening for The weak answer is "floating point is imprecise". The strong answer names *where* the imprecision falls relative to a specific limit, states that the disagreement is between two target types rather than a bug in either, and then observes that the check and the use are running on different representations — which is the same invariant that governs every parser differential.

  • Which comparison polarity is safer when a decoder may produce a not-a-number value?
    Write the rule as a positive requirement — proceed only when the value is at or below the limit — because every comparison against a not-a-number value is false, so the request fails closed. A rule phrased as "reject when above the limit" also evaluates false and therefore lets the value through. The polarity decides whether an unrepresentable value is refused or accepted.
  • Why do teams carry monetary amounts as strings on the wire?
    It takes the decoder's type choice out of the contract. A quoted `"10000.01"` with an agreed scale is parsed deliberately into a decimal on both sides, instead of passing through a generic number path that may target binary64 on one side and an arbitrary-precision decimal on the other. The cost is that every consumer must parse it explicitly, and a lenient binder may coerce it back into a float unless that is disabled.

saying these in an interview costs you the question

  • Says the wire number has a fixed width, so both sides must agree
  • Believes precision loss only affects fractions, not large integers
  • Assumes an out-of-range value always raises an error
  • Thinks comparing against a limit is safe for every decoded value
  • Treats quoted numerics as always rejected by every decoder