Why tonumber isn't enough in chat commands

tonumber accepts inf, nan, negatives and exponents, and math.clamp lets NaN through. How to read numbers from commands safely before they reach saved data.

warningLuaudit check: unchecked-command-numbers

What it looks like

A chat or admin command reads a number from typed text with tonumber and uses it with no proper range check: an amount of currency, a level, a walk speed, a ban length. tonumber accepts much more than digits, including inf, nan, -5, 1e999, 5.5 and 0x10.

Luau
local amount = tonumber(args[2])
if amount then
	profile.Coins += amount
end

How to detect it

  • Find every command that turns text into a number, usually with tonumber, and follow the number to where it's used.
  • Check for a lower bound, an upper bound and, where it matters, a whole-number check. A missing bound lets in negative or huge values.
  • Check how the bounds are written. NaN fails every comparison, so if amount > 1000 then return end lets it through, and math.clamp returns NaN unchanged. Checks written as "continue only if amount >= 1 and amount <= 1000" reject it.
  • Look hardest at numbers that reach saved data, leaderboards or other players.

What an exploiter could do

Commands are typed by staff, so this is mostly about mistakes and stolen staff accounts. A typo like 1e9, or inf, puts a value into saved data that no normal play can produce, and a negative amount takes currency away. NaN is worse: once it's in a balance, every later comparison on it is false, so checks like "has enough coins" stop working as intended. If any command is open to ordinary players, such as a ;pay command, anyone can do this on purpose.

How to fix it

  • Accept only the characters you expect, for example string.match(text, "^%d+$"), before calling tonumber.
  • Check the result with comparisons that NaN fails: continue only if amount >= 1 and amount <= limit.
  • Give each command its own limit, sized for what staff really need.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local PlayerData = require(script.Parent.PlayerData)

local staff: { [number]: boolean } = {
	[123456789] = true,
}

local function onChatted(player: Player, message: string)
	local name, text = string.match(message, "^;coins (%S+) (%S+)$")
	if not name or not text then
		return
	end
	local target = Players:FindFirstChild(name)
	local amount = tonumber(text)
	if not target or not target:IsA("Player") or not amount then
		return
	end
	local profile = PlayerData.get(target)
	if profile then
		profile.Coins += amount
	end
end

Players.PlayerAdded:Connect(function(player)
	if staff[player.UserId] then
		player.Chatted:Connect(function(message)
			onChatted(player, message)
		end)
	end
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local PlayerData = require(script.Parent.PlayerData)

local staff: { [number]: boolean } = {
	[123456789] = true,
}
local limit = 100000

local function onChatted(player: Player, message: string)
	local name, text = string.match(message, "^;coins (%S+) (%d+)$")
	if not name or not text then
		return
	end
	local target = Players:FindFirstChild(name)
	local amount = tonumber(text)
	if not target or not target:IsA("Player") or not amount then
		return
	end
	if not (amount >= 1 and amount <= limit) then
		return
	end
	local profile = PlayerData.get(target)
	if profile then
		profile.Coins += amount
	end
end

Players.PlayerAdded:Connect(function(player)
	if staff[player.UserId] then
		player.Chatted:Connect(function(message)
			onChatted(player, message)
		end)
	end
end)

Common false positives

  • Numbers that only pick from a fixed list, like ;map 2 indexing a table of maps, fail safely when the entry is nil. Check the lookup handles nil.
  • Purely cosmetic values that end with the server, like the time of day, matter less. Mention them only if NaN or infinity would break something.
  • Numbers that come through a remote rather than chat are covered by Numbers from the client without range checks in remote-events.