Parent : [[INDEX-FIVEM-FRAMEWORK]]
FiveM - NUI Callbacks securises : Protection contre injection et falsification
Probleme
Les NUI (Native UI) de FiveM permettent d'afficher des interfaces HTML/JS dans le jeu. Les callbacks NUI (RegisterNUICallback) communiquent du frontend vers le backend Lua client. Si ces callbacks declenchent des TriggerServerEvent sans validation, un joueur peut manipuler le NUI via la console du navigateur ou injecter du JS pour falsifier les appels.
Contexte
- NUI = Overlay HTML/JS/CSS dans FiveM (Cef en interne)
SendNUIMessage: Lua → JSRegisterNUICallback: JS → Lua- Le JS cote NUI est controle par le client (modifiable via console DevTools)
- Un callback NUI mal securise = vector d'attaque pour trigger des events serveur
Solution
1. RegisterNUICallback avec validation contextuelle
-- Le callback NUI ne fait que collecter, le serveur valide
RegisterNUICallback('requestDocumentSave', function(data, cb)
local src = PlayerId()
-- Validation 1 : Le joueur est-il bien en train d'utiliser l'interface ?
if not isPlayerUsingDocumentUI(src) then
cb({ success = false, error = 'UI not active' })
return
end
-- Validation 2 : Les donnees sont-elles dans le format attendu ?
if type(data.documentType) ~= 'string' or #data.documentType > 50 then
cb({ success = false, error = 'Invalid document type' })
return
end
-- Validation 3 : Le document type est-il valide ?
local validTypes = { 'id_card', 'license', 'report', 'mugshot' }
if not hasValue(validTypes, data.documentType) then
cb({ success = false, error = 'Unknown document type' })
return
end
-- On envoie au serveur avec un TOKEN de session NUI
local sessionToken = generateNUISessionToken(src)
TriggerServerEvent('documents:saveValidated', {
documentType = data.documentType,
fields = sanitizeFields(data.fields),
sessionToken = sessionToken,
timestamp = GetGameTimer()
})
cb({ success = true, token = sessionToken })
end)
local nuiSessions = {}
function generateNUISessionToken(playerId)
local token = string.format('%d_%d_%s', playerId, GetGameTimer(), math.random(100000, 999999))
nuiSessions[playerId] = { token = token, created = GetGameTimer(), expires = GetGameTimer() + 30000 }
return token
end
2. Server-side : Validation du token NUI
local activeNUISessions = {}
function validateNUISession(src, token)
local session = activeNUISessions[src]
if not session then return false end
if session.token ~= token then
activeNUISessions[src] = nil
return false
end
if GetGameTimer() > session.expires then
activeNUISessions[src] = nil
return false
end
activeNUISessions[src] = nil
return true
end
RegisterNetEvent('documents:saveValidated')
AddEventHandler('documents:saveValidated', function(data)
local src = source
if not validateNUISession(src, data.sessionToken) then
logSuspiciousActivity(src, 'Invalid NUI token for document save')
return
end
-- Validation metier...
saveDocument(src, data.documentType, data.fields)
end)
RegisterNetEvent('documents:openUI')
AddEventHandler('documents:openUI', function()
local src = source
local token = ('session_%d_%d_%d'):format(src, os.time(), math.random(1000000, 9999999))
activeNUISessions[src] = { token = token, created = GetGameTimer(), expires = GetGameTimer() + 300000, uiType = 'documents' }
TriggerClientEvent('documents:sessionCreated', src, token)
end)
3. Protection contre les devtools / injection JS
<script>
(function() {
'use strict';
const threshold = 160;
let devToolsOpen = false;
const checkDevTools = () => {
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
if (widthThreshold || heightThreshold) {
if (!devToolsOpen) {
devToolsOpen = true;
fetch('https://' + GetParentResourceName() + '/devToolsDetected', {
method: 'POST',
body: JSON.stringify({ detected: true })
});
}
}
};
setInterval(checkDevTools, 1000);
})();
</script>
4. Rate limiting sur les callbacks NUI
local nuiCallbackLimits = {}
function rateLimitNUICallback(callbackName, maxCallsPerSecond)
local now = GetGameTimer()
if not nuiCallbackLimits[callbackName] then
nuiCallbackLimits[callbackName] = { count = 0, windowStart = now }
end
local limit = nuiCallbackLimits[callbackName]
if now - limit.windowStart > 1000 then
limit.count = 0
limit.windowStart = now
end
limit.count = limit.count + 1
if limit.count > maxCallsPerSecond then
return false
end
return true
end
5. Isolation du NUI : pas de donnees sensibles dans le DOM
<script>
window.addEventListener('message', (event) => {
const data = event.data;
if (data.type === 'showDocument') {
document.getElementById('doc-content').textContent = data.content;
}
// PAS de donnees de permission ici
});
function onSaveButtonClick() {
const content = document.getElementById('doc-content').textContent;
fetch('https://' + GetParentResourceName() + '/requestSave', {
method: 'POST',
body: JSON.stringify({ content: content })
});
}
</script>
Checklist de validation
- Les callbacks NUI ne declenchent JAMAIS de
TriggerServerEventsans validation - Token de session NUI genere cote serveur et valide server-side
- Rate limiting sur tous les callbacks NUI modifiant l'etat
- Le NUI est "read-only" pour les permissions (pas de donnees sensibles dans le DOM)
- Detection DevTools cote client (layer de defense)
- Sanitization des donnees recues du NUI avant traitement
- Les callbacks NUI sont minimaux : collecte → validation → server event
- Pas de logique metier dans le JS du NUI (tout cote serveur)
- Obfuscation/minification du JS du NUI
- Sessions NUI avec expiration (TTL court)
- Log des tentatives de callback NUI avec token invalide
Anti-pattern associe
[[KNOW-ANT-FIVEM-002-client-side-validation-only|KNOW-ANT-FIVEM-002]]— Client-side validation only
References
- FiveM Docs : NUI https://docs.fivem.net/docs/scripting-manual/nui-development/
Liens connexes
- [[KNOW-ANT-FIVEM-002-client-side-validation-only|KNOW-ANT-FIVEM-002]]