Segurança
Um cliente FiveM é um programa rodando no computador de outra pessoa. Registradores de eventos (event loggers) e executores Lua são ferramentas comuns, disponíveis gratuitamente, e um servidor que só está seguro enquanto a própria página do telefone é quem conversa com ele não está seguro de verdade. Veja o que isso significa na prática aqui e onde vivem as regras.
Tudo abaixo foi revisado manipulador por manipulador (handler por handler), e onze pontos surgiram dessa revisão. Eles estão listados no final, não porque a lista em si seja curiosa, mas pelo formato dela: quatro deles eram exatamente o mesmo erro em quatro lugares diferentes.
Depois, a mesma pergunta foi feita na direção contrária — não o que um cliente pode enviar, mas no que o telefone acredita sobre o que recebe — e essa é a regra seis.
As seis regras
Seção intitulada “As seis regras”1. Quem você é vem do servidor, nunca da mensagem.
Phone.Handler(name, fn) chama fn(src, data, player), onde src é a conexão e player é buscado a partir dela. Nenhum manipulador aceita um identificador, um número de telefone ou um personagem vindo do cliente para decidir de quem são os dados que serão alterados. O destinatário é resolvido a partir de um número de telefone no banco de dados; o remetente é sempre quem fez a chamada.
Na única vez em que isso foi quebrado, mic_phone:boot foi registrado diretamente como Phone.Boot — e o segundo argumento de Phone.Boot é o registro a partir do qual o telefone deve ser construído, enquanto um callback entrega ao manipulador o que quer que o cliente tenha enviado como seu segundo argumento. Qualquer um podia pedir o telefone de qualquer pessoa pelo nome. server/tests.lua tem um teste que chama o callback registrado passando um personagem forjado e falha se receber um telefone de volta.
2. Os eventos internos de uma framework não são portas de entrada.
RegisterNetEvent abre um nome de evento para a rede neste recurso. O ESX dispara esx:playerLoaded e esx:playerLogout localmente, com TriggerEvent, e nunca os abre para a rede — portanto, registrá-los aqui como eventos de rede fazia o mic_phone aceitá-los de qualquer cliente, com qualquer ID de jogador dentro. Isso entrava direto em Phone.OnDropped: encerrava a chamada daquele jogador, tirava-o do rádio e esquecia o telefone dele.
Use AddEventHandler para qualquer coisa que a própria framework dispare. Um linter vai lhe dizer o contrário; o linter está errado, e segui-lo reabre a brecha.
3. Nada do que o cliente envia é repassado como chegou.
Qualquer coisa que vá ser armazenada ou mostrada para outra pessoa é reconstruída a partir dos campos que conhecemos, em vez de ser retransmitida inteira: Nearby.Shared para o que um telefone entrega a outro, attachment() para o que uma mensagem carrega, a solicitação em services:request, o item em citylist:save. Um campo que ninguém especificou não sobrevive à viagem.
4. Um endereço é verificado contra os hosts que este servidor usa.
Photos.AllowedUrl — exige https, menos de 600 caracteres e um host presente em ServerConfig.Media.Hosts. Toda imagem que chega a uma linha do banco de dados ou à tela de outro jogador passa por essa verificação: a câmera, compartilhamentos por proximidade, fotos de anúncios, anexos de mensagens, publicações e perfis em redes sociais.
Sem isso, um cliente modificado pode colocar um endereço de sua escolha na frente de cada jogador que rolar o feed — o que é uma forma de coletar o endereço IP de todo mundo no servidor — ou, como essas colunas são LONGTEXT, carregar uma imagem inteira embutida no próprio link.
5. Tudo tem um tamanho máximo e um limite de frequência.
DB.Fit(value, width) | uma string, cortada na largura da sua coluna |
DB.Json(value, limit) | uma tabela codificada em JSON, ou nada se não couber |
DB.SetJson | 128 KB por bloco; o maior bloco real já visto tem 929 bytes |
Phone.TooFast | 100 chamadas a cada 10 segundos, por jogador |
Photos.Chunk | 3 uploads simultâneos, 24 MB, limpos após um minuto de silêncio |
Contacts.Most | 200 |
Calls.MostRecents | 100 |
MOST_ATTACHMENTS | 8 |
Os tamanhos importam mais do que parece. O banco de dados roda em modo estrito (strict mode), então um valor apenas um caractere mais largo do que a coluna não é aparado — a inserção falha no meio do que quer que aquele manipulador estivesse fazendo. Em uma transferência bancária, essa linha vem depois que o dinheiro já se moveu.
6. O que outro jogador escreveu é texto, e a página é construída para que nunca deixe de ser texto.
As cinco regras acima tratam do que chega ao servidor. Esta trata do que chega ao telefone, e é a direção mais perigosa: a página que desenha o perfil de outra pessoa é a mesma página que pode chamar todos os manipuladores em nome do dono do telefone. Uma tag HTML/script que consiga rodar ali pode ler as mensagens daquele jogador e transferir o dinheiro dele. Não precisa de um executor — só precisa de um nome.
O telefone desenha a interface usando template literals, portanto qualquer valor vira marcação HTML a menos que algo faça o escape dele. escapeHtml em core/app.js faz esse papel, e rpEsc nos aplicativos da cidade é a mesma função com um nome local. Isso se divide em duas metades:
Fazer o escape no ponto em que é escrito. Toda interpolação que carrega palavras de alguém — um nome, um @handle, uma legenda, uma nota, um bairro, um endereço — passa por uma dessas duas funções. Isso foi verificado analisando (parsing) todos os template literals em html/ em vez de apenas lê-los no olho, porque existem 2.918 deles.
E não depender apenas disso. html/index.html traz uma Content-Security-Policy sem 'unsafe-inline' em script-src. Nada neste telefone é um script inline ou um atributo de evento inline — todo script é um arquivo —, então a política não custa nada e garante que um onerror que uma página tenha sido enganada para escrever simplesmente não seja executado. É a metade da proteção que não depende de a próxima pessoa que escrever uma função de renderização se lembrar de tudo. server/web.js adiciona as duas coisas que uma tag <meta> não pode definir: frame-ancestors 'none', porque o link de um telefone vinculado é o segredo inteiro e esse telefone pode enviar dinheiro, e nosniff.
tests/verify-injection.js entrega a cada aplicativo uma string que seria inconfundível se deixasse de ser texto puro, e depois procura por ela na página renderizada.
E o que o servidor envia sem ser perguntado. Os arquivos em shared/config/*.lua são shared_scripts: cada linha deles chega ao cliente de cada jogador e pode ser lida de dentro do jogo com um executor. Config.Web guardava o domínio pelo qual este servidor responde, o endereço para o qual o QR Code aponta e — duas vezes — o endereço de e-mail do dono do servidor, informações que nenhum cliente jamais precisou ler. Agora eles vivem em shared/server_config.lua, que o fxmanifest.lua lista em server_scripts e que já vem acompanhado de um arquivo de exemplo em branco. Um arquivo de configuração compartilhado não é um lugar privado, e os ajustes que descrevem este servidor específico devem ficar fora dele.
Onde é fácil errar
Seção intitulada “Onde é fácil errar”O caminho cuidadoso e o caminho ao lado dele. Quatro dos onze problemas foram exatamente o mesmo erro: uma função que tratava o caso que alguém tinha em mente e deixava o outro passar direto. data: passava pelo upload e url não, em quatro lugares distintos, cada um escrito em um momento diferente por alguém que tinha acabado de tomar cuidado três linhas acima. Se você adicionar uma ramificação (if/else) a qualquer um deles, pergunte-se o que o outro lado faz.
Um limite generoso ainda é um limite. A lista de contatos aguarda uma consulta ao banco por contato, e uma inserção leva cerca de um terço de segundo no servidor em que isto foi escrito: quinhentos contatos significavam quase três minutos de banco de dados preso por uma única requisição. O número não estava errado por ser grande — estava errado porque ninguém o havia multiplicado pelo tempo de execução.
As regras vivem em uma única função, não espalhadas em cada manipulador. Tudo na tabela acima fica centralizado em um só lugar. Essa é a única maneira de uma regra sobreviver quando a próxima pessoa adicionar um manipulador novo.
Uma verificação que para cedo demais não é uma verificação. Photos.AllowedUrl lia o host e parava na primeira barra (/) — e tudo o que vem depois dessa barra é justamente a parte que o cliente escreve. https:// + um host permitido + /x.jpg" onerror="… passava no teste, e o telefone colocava essa string dentro do src de uma tag img para cada jogador que abrisse o anúncio. A regra estava certa; mas só era perguntada sobre um terço do endereço.
Os onze pontos no servidor
Seção intitulada “Os onze pontos no servidor”| O que permitia que alguém fizesse | Onde |
|---|---|
| Encerrar a chamada de qualquer jogador e derrubar seu rádio à vontade | esx:playerLogout registrado como evento de rede |
| Encher a memória do servidor até ele cair | uploads sem limite e sem rotina de limpeza |
| Mover dinheiro sem registro e receber mensagem de que a operação falhou | uma nota mais larga que a coluna, em modo estrito |
| Colocar qualquer endereço na galeria de fotos de outro jogador | compartilhamentos por proximidade |
| Colocar qualquer endereço na frente de todos os jogadores da cidade | fotos de anúncios |
| Colocar qualquer endereço na frente de todos em uma conversa | anexos de mensagens |
| Colocar qualquer endereço na frente de todos em um aplicativo | publicações e perfis de redes sociais |
| Chamar qualquer manipulador na velocidade em que conseguisse enviar | falta de limite de frequência (rate limit) no jogo |
| Armazenar o quanto quisesse, repetidamente | configurações, lugares, álbuns, preferências |
| Adicionar o que quisesse a uma requisição de saída do servidor | time colado em uma URL sem escape |
| Ler o telefone de qualquer personagem | mic_phone:boot, corrigido anteriormente |
Todos os onze têm um teste em server/tests.lua agora, subindo dos cinco originais.
Dois deles merecem destaque. Fotos de anúncios e publicações sociais só são alcançadas passando por Photos.AllowedUrl — portanto, um teste apenas dessa função diz que a regra está certa, mas não diz nada sobre se aqueles manipuladores continuam chamando a função. O teste the handlers that take a photo still ask whether it is one entra pela porta da frente, como faz um telefone vinculado, e verifica a recusa: quando a verificação é removida para teste, o endereço forjado é aceito, o anúncio é gravado e transmitido para todos os telefones da cidade, a publicação é gravada e mostrada para todos no aplicativo, e o perfil responde com:
without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)que é exatamente a string que teria entrado em uma tag img. A execução que passa no teste não grava nada no banco; a rotina de limpeza ao lado dela existe para quando o teste falha, que é quando realmente importa.
O teste do evento de framework é o único diferente: não há como perguntar ao runtime quais nomes de eventos um recurso abriu para a rede, então ele lê o arquivo bridge/framework/server.lua diretamente e procura pelas palavras. É mais simples que os outros, mas está lá mesmo assim — porque o erro que ele evita é justamente um que os linters recomendam ativamente.
E os cinco na direção oposta
Seção intitulada “E os cinco na direção oposta”| O que permitia que alguém fizesse | Onde |
|---|---|
| Executar código no telefone de todo mundo que olhasse seu perfil | nome, @handle, bairro, emprego ou interesse colados diretamente no HTML |
| Executar código no telefone de todo mundo que abrisse um anúncio | endereço de foto colado no src e uma validação de endereço que permitia aspas |
| Executar código no telefone de qualquer um lendo uma fatura, garagem ou empresa | emissores, títulos, placas, localizações e detalhes da empresa colados sem tratamento |
| Permitir que qualquer um dos itens acima realmente fosse executado | ausência total de Content-Security-Policy na página |
| Colocar o telefone dentro da página de outro site | ausência de frame-ancestors na porta web |
Os três primeiros são um erro cada; os dois últimos são o motivo pelo qual os três primeiros tinham impacto crítico. Todos os cinco são cobertos por tests/verify-injection.js, que inspeciona a página que um navegador teria construído em vez de olhar apenas para as strings que entraram nela.
Ambos os conjuntos de testes foram observados falhando primeiro, removendo-se a correção: o teste de endereços aponta as três recusas ausentes, e o de injeção conta quatro elementos que escaparam de seus atributos — e não diz nada sobre algum script ter executado, porque àquela altura a política de segurança (CSP) já estava ativa.