How to range-check numbers sent to RemoteEvents
A typeof check alone lets negative numbers, NaN and infinity through. How to write range checks for amounts, quantities and stock that reject all three.
What it looks like
A handler checks that an argument is a number but not which number. It then uses it in maths or comparisons that decide currency, stock, health or quantities.
if typeof(amount) ~= "number" then return end if profile.Coins < amount then return end profile.Coins -= amount
How to detect it
- Find every client argument that is checked with
typeof(x) == "number"(or not checked at all) and then used in arithmetic, a loop bound, a table index or a comparison. - Check each one for all of these: a lower bound (often greater than zero), an upper bound, whole numbers where only whole numbers make sense, and protection against NaN and infinity.
- Pay close attention to guards written as rejections, like
if balance < amount then return end. Every comparison with NaN is false, so NaN slips past a rejection guard. A guard written as an acceptance, likeif amount >= 1 and amount <= balance then, rejects NaN. math.hugepassestypeofand most lower-bound checks. Look for an upper bound.
What an exploiter could do
With a negative number, a transfer runs backwards: a donation or a sale takes from the other side instead of giving. With NaN, balance checks pass and the stored value becomes NaN, which then spreads into every sum and makes every later comparison false. With infinity or very large numbers, they can make loops run far longer than intended or push stock and rewards past any sensible limit. With fractions, they can buy 0.01 of an item that the rest of the game treats as a whole one.
How to fix it
- Check the type, then write the range check so the value must pass it:
amount >= 1 and amount <= limit. NaN fails this, and so does infinity once there's an upper bound. - Add
amount % 1 == 0when only whole numbers make sense. NaN and infinity fail this too. - Pick an upper bound that fits the feature, such as the player's balance, a stack size or a batch limit.
Vulnerable example
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) ReplicatedStorage.Remotes.Donate.OnServerEvent:Connect(function(player: Player, target: unknown, amount: unknown) if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then return end if typeof(amount) ~= "number" then return end local from = PlayerData.get(player) local to = PlayerData.get(target) if not from or not to or from.Coins < amount then return end from.Coins -= amount to.Coins += amount end)
Fixed example
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) ReplicatedStorage.Remotes.Donate.OnServerEvent:Connect(function(player: Player, target: unknown, amount: unknown) if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then return end if typeof(amount) ~= "number" then return end local from = PlayerData.get(player) local to = PlayerData.get(target) if not from or not to then return end if not (amount >= 1 and amount <= from.Coins and amount % 1 == 0) then return end from.Coins -= amount to.Coins += amount end)
Common false positives
- A number that is clamped before use, for example with
math.clamp, is safe from range abuse once NaN is handled.math.clamppasses NaN through, so check the code rejects NaN first. - A number used only to choose from a fixed list, like
options[index], where a missing entry is handled, can't move value around. It still needs a type check. - Numbers that come from the server, such as a value read from the player's own profile, are not client input.