Scripting Lua

Validar o que vem do cliente: o NaN que passa por todas as suas checagens

Todo dado que o cliente manda precisa ser validado no servidor. Este guia percorre as quatro camadas que a documentação oficial descreve, com o caso do NaN, que é do tipo number e mesmo assim escapa de qualquer comparação.

por Gustavo Cantino10 min de leituraIntermediário

Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Placa tipográfica com a categoria Scripting Lua em destaque, o nível intermediário abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

A frase que sustenta toda a segurança de um jogo Roblox cabe numa linha, e a documentação oficial a escreve sem rodeio: todo dado enviado pelo cliente precisa ser validado pelo servidor antes de ser usado.

O problema é que "validar" soa como uma coisa só, e são quatro — em camadas, com ordem entre elas. Pular uma delas não produz erro; produz um jogo que funciona perfeitamente até alguém descobrir o buraco. Este guia percorre as quatro, e termina no caso que derruba as defesas mais bem-intencionadas: o NaN.

O problema

A porta existe por desenho. Toda vez que o cliente pode disparar uma ação no servidor existe potencial de abuso — não é falha de implementação, é a natureza de ter cliente e servidor.

E a porta é mais larga do que a maioria imagina. RemoteEvent e RemoteFunction são o vetor mais comum, mas a documentação lembra que outras instâncias, como ProximityPrompt, também são suscetíveis. Quem blindou só os remotes blindou a parte que estava olhando.

O desenho que mais aparece em jogo pequeno é o mesmo em todo lugar: uma pasta na ReplicatedStorage com os itens da loja, cada um com um NumberValue de preço, e um remote que recebe "o item que o jogador quer comprar". É conveniente, é legível — e é exatamente o formato que o exemplo oficial usa para mostrar o ataque.

Vale nomear o engano que sustenta tudo isso, porque ele não é burrice: é economia. O cliente já sabe se o jogador tem dinheiro, se está perto da loja, se o item existe. Escrever a checagem lá é natural, o botão fica desabilitado, o jogo se comporta certo, e a tela nunca mostra um estado inválido. O problema é que nada disso é uma trava — é uma conveniência de interface. O código do cliente roda na máquina de quem joga, e quem joga pode trocá-lo por outro. Toda regra que existe só no cliente é uma regra que o jogo pede com educação.

O teste mental que resolve a dúvida caso a caso é sempre o mesmo: se o cliente mandasse qualquer coisa por este remote, o que aconteceria? Se a resposta for alguma coisa diferente de "nada", falta validação. E "o cliente nunca mandaria isso" não é resposta — o cliente que importa é o que foi reescrito para mandar.

Passo a passo

Camada 1 — contexto e permissão. Antes de olhar o dado, olhe quem está pedindo. O servidor precisa verificar se o jogador tem permissão para fazer aquele pedido: ele está perto o bastante da loja para comprar? Tem a chave que abre a porta? O personagem está vivo?

A documentação lista três situações em que essa camada é obrigatória: remote que concede recompensa ou mexe em progressão, remote que afeta outros jogadores ou estado compartilhado, e remote com exigência de distância, tempo ou permissão. Se o seu remote cai em qualquer uma dessas, a checagem de contexto não é opcional.

Esta é a camada que mais se esquece, porque ela não tem cara de segurança — tem cara de regra de jogo. O cliente já sabe que o jogador está longe da loja, então o botão nem aparece. O servidor não sabe de nada disso.

Camada 2 — tipo e estrutura. Aqui entram dois ataques diferentes.

O primeiro é o volume: mandar tipos tecnicamente válidos, mas gigantes, longos ou malformados. Se o servidor faz uma operação cujo custo cresce com o tamanho da string, uma string enorme ou malformada serve para atolar o servidor. O tipo está certo; o tamanho é a arma.

O segundo é mais elegante e mais caro: mandar uma tabela no lugar de uma Instance. Payload complexo consegue imitar o que seria uma referência de objeto comum — uma tabela com Name, ClassName, Parent e um Price aninhado com Value zerado tem a mesma forma de leitura que o objeto real. Se o seu código só lê campos, ele não percebe a diferença.

E o exemplo oficial faz questão de anotar o detalhe que transforma o roubo em prejuízo: valor negativo no preço pode virar entrega de moeda em vez de cobrança. Não é só comprar de graça — é o jogo pagando o comprador.

A defesa é curta e é sempre a mesma: typeof(item) ~= "Instance" derruba a tabela, e item:IsDescendantOf(pastaDeItens) derruba a Instance verdadeira que veio de outro lugar. As duas juntas, nunca uma só.

Camada 3 — valor. Além do tipo, é preciso validar os valores que passam por RemoteEvent e RemoteFunction, garantindo que façam sentido no contexto pedido. A documentação é clara sobre onde isso é essencial: remote que lida com moeda, item, número ou conteúdo do usuário.

Os exemplos são do tipo que parece óbvio depois de escrito: a quantidade comprada precisa ser maior que zero, e o id pedido precisa corresponder a um item real do jogo.

Camada 4 — taxa. Não adianta cada chamada ser válida se cabem centenas por segundo. Essa camada não substitui as outras; ela existe porque validação cara chamada em repetição vira o próprio ataque.

E ela pega uma família de problemas que nenhuma das outras três pega. Um remote de "abrir baú" pode estar perfeitamente validado — jogador vivo, perto do baú, baú existe — e ainda assim ser chamado sessenta vezes no mesmo quadro, esvaziando a recompensa de um dia numa fração de segundo. Cada chamada, sozinha, é legítima. O abuso está no ritmo.

O intervalo certo não é um número universal: ele sai do jogo. A pergunta é quanto tempo a ação leva para um humano executar de verdade, e o limite fica um pouco abaixo disso. Apertado demais, jogador com conexão instável é punido por algo que não fez. Frouxo demais, não protege nada.

Artefato

O módulo abaixo junta as quatro camadas num lugar só. Ele não decide nada de jogo: devolve (ok, motivo) e deixa o handler do remote desistir no primeiro false.

A ordem dentro de Validacao.numero não é estética, e é o ponto do artigo inteiro: a checagem de finitude vem antes da checagem de faixa. A próxima seção explica por quê.

Erro comum

O erro comum não é esquecer de validar. É validar e achar que acabou.

A documentação avisa que tanto inf quanto NaN são tipos numéricos válidos. O NaN é perigoso justamente por causa do tipo dele: para o Luau, NaN é um número como qualquer outro. E ele falha em todas as comparações padrão, o que permite atravessar checagens que parecem seguras.

O exemplo oficial usa um sistema de troca. O servidor recebe a quantidade de ouro oferecida e faz duas checagens que qualquer revisor aprovaria: confere o tipo, confere a faixa. Os comentários do próprio código da documentação contam o que acontece — na checagem de tipo, isso passa, porque o tipo de NaN é número; na checagem de faixa, isso é contornado, porque nenhuma comparação com NaN devolve verdadeiro. Nem menor que zero, nem maior que o teto. As duas dão falso, o código segue em frente, e o servidor aceita uma oferta que não é um número.

O estrago depois disso é livre: NaN contamina qualquer conta que toque nele, e uma vez gravado num DataStore vira saldo permanente que nenhuma comparação consegue corrigir — porque toda comparação com ele continua dando falso.

A correção cabe numa linha, e é a que a documentação recomenda: use math.isfinite() para detectar e recusar esses valores. O detalhe é onde a linha entra. Colocada depois da checagem de faixa, ela não protege nada, porque o código já passou. Ela é a primeira coisa depois da checagem de tipo, sempre.

Próximo passo

Com as quatro camadas no lugar, o passo seguinte é parar de confiar na memória: toda vez que a validação mora dentro do handler, ela é copiada, adaptada e — no terceiro remote — esquecida.

Centralizar como no módulo acima muda a pergunta de revisão. Em vez de "este handler valida?", que exige ler o corpo inteiro de cada um, a pergunta vira "este handler chama Validacao antes de mexer em estado?", que se responde olhando as primeiras linhas.

O exercício que vale fazer hoje, e que costuma render mais do que qualquer refatoração: liste todos os OnServerEvent e OnServerInvoke do seu jogo num lugar só. Para cada um, escreva ao lado qual das quatro camadas ele tem. A lista quase sempre é mais curta do que se imagina — e os buracos aparecem sozinhos, sem precisar ler código.

Vale lembrar que ProximityPrompt entra nessa lista. Ele não parece um remote, mas a documentação o cita ao lado deles justamente porque é uma ação que o cliente dispara no servidor. Prompt com MaxActivationDistance generoso e nenhuma checagem de distância no servidor é o mesmo buraco com outra roupa.

Depois disso, vale percorrer os outros tópicos de segurança que a própria documentação indica na barra lateral — a fronteira cliente-servidor é a porta mais movimentada, mas não é a única.

Fontes

Como corrigimos erros

Artefato pronto pra usar

Módulo de validação de entrada de remote, com as quatro camadas (ReplicatedStorage ou ServerStorage)
lua
-- Validacao.lua — um lugar só para conferir o que chega do cliente.
--
-- As quatro camadas da documentação, na ordem em que vale rodar:
--   1. contexto/permissão  (este jogador PODE pedir isso?)
--   2. tipo e estrutura    (é mesmo Instance? é mesmo string curta?)
--   3. valor               (está dentro da faixa? é finito?)
--   4. taxa                (quantas vezes por segundo?)
--
-- Cada função devolve (ok, motivo). O handler do remote nunca decide
-- sozinho: ele pergunta aqui e desiste no primeiro "false".

local Validacao = {}

local TAMANHO_MAXIMO_DE_TEXTO = 200

-- ── camada 2: a tabela disfarçada de Instance ────────────────────────
-- typeof() de uma tabela devolve "table", nunca "Instance". E mesmo uma
-- Instance de verdade precisa estar DENTRO da pasta que você controla.
function Validacao.instanciaDe(valor, pasta)
	if typeof(valor) ~= "Instance" then
		return false, "nao e Instance"
	end
	if not valor:IsDescendantOf(pasta) then
		return false, "Instance fora da pasta permitida"
	end
	return true
end

-- ── camada 2: texto ─────────────────────────────────────────────────
function Validacao.texto(valor)
	if typeof(valor) ~= "string" then
		return false, "nao e string"
	end
	if #valor == 0 or #valor > TAMANHO_MAXIMO_DE_TEXTO then
		return false, "tamanho de string fora do aceito"
	end
	return true
end

-- ── camada 3: número ────────────────────────────────────────────────
-- A ORDEM AQUI NÃO É ESTÉTICA. math.isfinite() vem ANTES da faixa,
-- porque NaN passa reto por qualquer comparação de menor/maior.
function Validacao.numero(valor, minimo, maximo)
	if typeof(valor) ~= "number" then
		return false, "nao e number"
	end
	if not math.isfinite(valor) then
		return false, "numero nao finito (NaN ou inf)"
	end
	if valor < minimo or valor > maximo then
		return false, "numero fora da faixa"
	end
	return true
end

function Validacao.inteiroPositivo(valor, maximo)
	local ok, motivo = Validacao.numero(valor, 1, maximo)
	if not ok then
		return false, motivo
	end
	if valor % 1 ~= 0 then
		return false, "esperava inteiro"
	end
	return true
end

-- ── camada 1: contexto ──────────────────────────────────────────────
function Validacao.vivoEPerto(player, alvo, distanciaMaxima)
	local character = player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return false, "personagem morto ou ausente"
	end

	local raiz = character:FindFirstChild("HumanoidRootPart")
	if not raiz or not alvo then
		return false, "sem posicao para comparar"
	end

	if (raiz.Position - alvo.Position).Magnitude > distanciaMaxima then
		return false, "longe demais do alvo"
	end
	return true
end

-- ── camada 4: taxa ──────────────────────────────────────────────────
local ultimoUso = {}

function Validacao.taxa(player, chave, intervalo)
	local agora = os.clock()
	ultimoUso[player] = ultimoUso[player] or {}
	local anterior = ultimoUso[player][chave]
	if anterior and (agora - anterior) < intervalo then
		return false, "rapido demais"
	end
	ultimoUso[player][chave] = agora
	return true
end

game:GetService("Players").PlayerRemoving:Connect(function(player)
	ultimoUso[player] = nil
end)

return Validacao
Neste artigo

Continue por aqui