Explorer
KNOW-PAT-080

FiveM - Architecture Server-Authoritative : State Bags et Anti-Cheat fondamental

Domaine
framework
Type
pattern
Priorité
P1

Parent : [[INDEX-FIVEM-FRAMEWORK]]

FiveM - Architecture Server-Authoritative : State Bags et Anti-Cheat fondamental

Problème

Dans FiveM, le client est entièrement contrôlé par le joueur (moddable via Lua injecté, trainers, menus). Toute logique critique exécutée côté client est vulnérable au spoofing : santé, position, argent, inventaire, permissions. Sans architecture server-authoritative, un cheateur peut manipuler toutes ces données.

Contexte

FiveM est un framework GTA V multiplayer où :

  • Le client exécute du Lua local (injectable/modifiable)
  • Le serveur exécute du Lua server-side (contrôlé par l'hôte)
  • Les events réseau transitent entre client et serveur
  • Les state bags permettent une synchronisation server-authoritative native

Solution

1. State Bags : La source de vérité unique

-- Le serveur est la source de vérité
-- server.lua
local playerHealth = {}

-- State bag : synchronisé automatiquement client → serveur → autres clients
AddEventHandler('playerConnecting', function(_, _, deferrals)
    local src = source
    Player(src).state:set('health', 100, true)  -- true = répliqué aux clients
end)

-- Le serveur valide TOUTE modification de santé
RegisterNetEvent('medical:requestHeal')
AddEventHandler('medical:requestHeal', function()
    local src = source
    local currentHealth = Player(src).state.health or 100
    
    -- Validation métier : le joueur a-t-il le droit de se soigner ?
    if not canHeal(src) then
        print(('[%s] Tentative de heal non autorisée'):format(src))
        return
    end
    
    -- Le serveur calcule et impose la nouvelle valeur
    local newHealth = math.min(currentHealth + 20, 100)
    Player(src).state:set('health', newHealth, true)
    
    -- Le client reçoit l'ordre du serveur (pas l'inverse)
    TriggerClientEvent('medical:setHealth', src, newHealth)
end)

2. Synchronisation positionnelle server-authoritative

RegisterNetEvent('player:heartbeat')
AddEventHandler('player:heartbeat', function(clientPos)
    local src = source
    local ped = GetPlayerPed(src)
    local serverPos = GetEntityCoords(ped)
    
    -- Tolérance : 10m (lag + interpolation) + vitesse max * deltaTime
    local maxDistance = 10.0 + (50.0 * 0.5)
    local dist = #(vector3(clientPos.x, clientPos.y, clientPos.z) - serverPos)
    
    if dist > maxDistance then
        print(('[%s] Teleport detecte : %.1fm'):format(src, dist))
        SetEntityCoords(ped, serverPos.x, serverPos.y, serverPos.z, false, false, false, false)
        Player(src).state:set('suspectedTeleport', true, false)
    end
end)

3. Validation des actions critiques

function MedicalSystem:treatPatient(medicId, patientId, treatmentType)
    if not GetPlayerName(medicId) or not GetPlayerName(patientId) then
        return false, 'Joueur introuvable'
    end
    
    local medicJob = self:getPlayerJob(medicId)
    if medicJob ~= 'ambulance' then
        self:logCheat(medicId, 'treatPatient sans job EMS')
        return false, 'Permission insuffisante'
    end
    
    local medicPos = GetEntityCoords(GetPlayerPed(medicId))
    local patientPos = GetEntityCoords(GetPlayerPed(patientId))
    if #(medicPos - patientPos) > 5.0 then
        return false, 'Patient trop loin'
    end
    
    local patientHealth = Player(patientId).state.health or 100
    if patientHealth >= 100 then
        return false, 'Patient deja soigne'
    end
    
    local newHealth = math.min(patientHealth + Config.TreatmentHeal[treatmentType], 100)
    Player(patientId).state:set('health', newHealth, true)
    self:logAction(medicId, patientId, 'treat', treatmentType, newHealth)
    
    return true, 'Traitement applique'
end

4. Rate limiting des events critiques

local EventRateLimiter = {}
EventRateLimiter.cooldowns = {}

function EventRateLimiter:check(src, eventName, maxPerSecond)
    local key = src .. ':' .. eventName
    local now = GetGameTimer()
    
    if not self.cooldowns[key] then
        self.cooldowns[key] = { count = 0, windowStart = now }
    end
    
    local window = self.cooldowns[key]
    if now - window.windowStart > 1000 then
        window.count = 0
        window.windowStart = now
    end
    
    window.count = window.count + 1
    if window.count > maxPerSecond then
        return false
    end
    return true
end

Checklist de validation

  • TOUTE donnée critique (sante, argent, position, inventaire) est stockee server-side
  • State bags utilisees pour la synchronisation (Player(src).state:set())
  • Le client ne fait que REQUESTER, le serveur APPROUVE et EXECUTE
  • Validation de distance server-side pour toutes les interactions physiques
  • Validation de job/permission server-side (pas cote client)
  • Rate limiting sur les events modifiant l'etat
  • Logs d'audit pour les actions critiques (soins, arrestations, transactions)
  • Le serveur peut override la position du client (SetEntityCoords)
  • Pas de TriggerServerEvent avec des donnees non-validees (position, sante)
  • Anti-cheat server-side pour la vitesse de deplacement (speed hack)

Anti-pattern associe

  • [[KNOW-ANT-FIVEM-002-client-side-validation-only|KNOW-ANT-FIVEM-002]] — Client-side validation only

References

Liens connexes

  • [[KNOW-ANT-FIVEM-002-client-side-validation-only|KNOW-ANT-FIVEM-002]]