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.

criticalLuaudit check: unsafe-numbers

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.

Luau
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, like if amount >= 1 and amount <= balance then, rejects NaN.
  • math.huge passes typeof and 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 == 0 when 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

Vulnerable
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

Fixed
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.clamp passes 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.