Skip to content

Network events: checking what the client sends

A network event is a message a player’s game sends to your server (TriggerServerEvent). A cheat can send any event, with any argument, at any rate. The protection is on the server: Bastrel checks the call before your handler runs, and your handler only sees calls that passed.

You register an event through Bastrel instead of RegisterNetEvent + AddEventHandler:

-- fxmanifest.lua of YOUR resource
dependency 'bastrel'
exports.bastrel:registerNetEvent('farm:harvest', {
{ 'string', maxLen = 32, pattern = '^[%w_]+$' }, -- pointId: the client only sends which point
}, {
rate = { max = 3, per = 10000 }, -- 3 calls per 10 s and per player
acl = { jobs = { 'farmer' } },
context = { 'alive', 'notInVehicle' },
}, function(src, pointId)
local point = Points[pointId]
if not point then return end
exports.ox_inventory:AddItem(src, point.item, point.amount) -- the amount is decided by the server
end)
  • The handler gets src (the player’s server id) as its first argument. Do not read the global source in it.
  • Never take an amount, a price or an item from the client. Send an identifier and decide the rest on the server.
  • Register your events when your resource starts. The exports of another resource are available after the first tick of the scheduler: dependency 'bastrel' makes sure bastrel is started first. Registering before bastrel is ready is an error in your resource.
  • registerNetEvent answers true, or false, reason (and logs netevents.register_failed) when the schema or an option is wrong, the handler is missing, or the event name is already registered by Bastrel (one registration per name).
  • Names that belong to the game or to Bastrel are refused: the server events of the official reference (playerDropped, playerConnecting, onResourceStart, entityCreating, weaponDamageEvent, …), the events Bastrel handles itself, and any name starting with bastrel: or __cfx_.
  • The handler can be any function. Your function reaches Bastrel as a function reference (a table, not a Lua function, because it crosses a resource boundary). Bastrel accepts a Lua function, or a table whose metatable (as getmetatable returns it) has a __call that is a Lua function; it refuses a number, a string, a table without such a __call, a __call that is itself a callable table, and a protected metatable that is not a table (on purpose, to stay conservative). The error is the handler must be a function or a function reference.
  • The handler is called under pcall: an error it raises does not stop the other events. For a handler of Bastrel’s own resource it is logged (netevents.handler_failed). For a handler of YOUR resource (a function reference) the function runs in your resource: FiveM prints the error there, and netevents.handler_failed is still logged but without the message (error is "nil", observed in game). Read your own console for the error.
  • Restarts. If you restart the resource that registered an event, Bastrel releases it when that resource stops (netevents.released) and your resource registers it again when it starts: restart my_shop works. The name stays reserved for that resource until bastrel restarts: while it is stopped, another resource that tries to register the same event is refused (false, reason, logged as netevents.name_reserved), so a restart never opens a window for someone else to take your event. If you restart bastrel, every registration is lost: register your events again when bastrel starts, with AddEventHandler('onResourceStart', function(resource) if resource == 'bastrel' then register() end end) (and once at your own start if GetResourceState('bastrel') == 'started'). Until then your events are not protected by Bastrel and not registered with the game.
exports.bastrel:registerCallback('farm:price', { { 'string', maxLen = 32 } }, nil, function(src, item)
return Prices[item]
end)

Same schema, options and controls; it wraps lib.callback.register. A refused call returns nothing to the client. ox_lib must be installed and started before bastrel (it is a dependency of bastrel): its init.lua is loaded by Bastrel’s manifest.

The cheapest first, so that a flood cannot use up the server:

  1. Source: a call that does not come from a player Bastrel tracks (for example a local TriggerEvent) is ignored, without a detection.
  2. Rate (only if you give rate): a token bucket per player and per event. Beyond it: BAS-NE-002. There is no default rate: an event without rate is never cut, so a busy interface callback is safe.
  3. Size of the whole payload (arguments, nesting, keys, string length, total bytes) against netevents.limits, before anything is copied: BAS-NE-001.
  4. Schema (types, bounds, patterns): BAS-NE-001.
  5. ACL (acl): BAS-NE-003.
  6. Context (context): BAS-NE-004.
  7. Client SDK (BAS-NE-005): see sdk.md. It is a faint clue that never blocks a call, kept out of the score until you calibrate sdk in config/whitelists.lua.

A refused call never reaches your handler. A function sent by a client in an argument (it arrives as a function reference, not as data) is refused as BAS-NE-001 (bad_value), so it kicks in enforce; Bastrel never indexes it, so it cannot cause an internal error that would hide the detection. The same goes for a function reference given where a schema or an option expects a table: registerNetEvent answers false, reason.

A list with one entry per argument:

Entry Meaning
{ 'string', minLen?, maxLen?, pattern? } pattern is a Lua pattern, matched anywhere: anchor it ('^...$').
{ 'number', min?, max?, integer? } Finite numbers only (NaN and infinity are refused). integer = true accepts 3 and 3.0, refuses 1.5; your handler always receives a Lua integer (3.0 is converted to 3). { 'integer', ... } is the same.
{ 'boolean' }
{ 'vector2' }, { 'vector3' }, { 'vector4' } A value whose Lua type is that vector (type(vector3(1, 2, 3)) is 'vector3' in FiveM’s Lua). Components must be finite. How a vector sent by a client arrives in a network event is not documented by FiveM and not yet captured: test your event with a real client before relying on it, or send the components as numbers.
{ 'table', fields? = { key = entry }, items? = entry, maxItems? } fields: exactly these keys (optional ones may be absent), no other. items: a list whose elements all match. Neither: any table within the size limits.
optional = true on any entry The argument may be nil.

An extra argument (a trailing nil beyond the schema is not one: Lua cannot tell it from no argument), a function, a key that is neither a string nor a number, or a self-referencing table is refused. A schema with a mistake (unknown type, misspelt field, a pattern that does not compile) refuses the registration instead of silently accepting everything.

Option Meaning
rate = { max, per } At most max calls per per ms, per player and per event.
acl All the keys you give must pass. jobs = { 'farmer' }: the player’s job is one of them. grades = 2: grade 2 or more (or { farmer = 2, baker = 1 }: the minimum for the job). groups = { 'crew' }: the player has one of these groups. ace = 'farm.use' (or a list): one of these ACEs is allowed. Job, grade and groups come from the framework adapter (QBX). An ACL on jobs, grades or groups needs a framework adapter: on a server without one, or while a player’s character is not loaded yet, such an event is refused (see below). ace does not need the adapter. If the game cannot answer the ACE check (unknown answer), the call is refused with no detection.
context A list: 'alive', 'notInVehicle', 'notCuffed', { near = { coords = vector3(...), radius = 3.0 } } (the player’s ped is within radius of fixed coords), { bucket = 0 } (the routing bucket).
limits Size limits for this event only, same keys as netevents.limits.
requireSdk true by default (requireSdkDefault): the event is announced to the client SDK (sdk.md) and a call without SDK data is BAS-NE-005. false: never announced, never judged.
onViolation 'flag', 'drop' or 'ban', see below.

When Bastrel cannot know something, it never accuses the player:

  • ACL: the call is refused, with no detection and no score (the ACL fails closed, otherwise a player whose character is still loading would reach a restricted event). One warning line netevents.control_unknown per event and check says so. Without a framework adapter, every event with jobs, grades or groups is refused.
  • Context: the framework answer comes first (alive uses the framework, then the health of the ped only if the framework does not answer). When nothing answers, that check lets the call through (BAS-NE-004 is unverified) and logs the same warning.
Code Cause Effect
BAS-NE-001 Payload outside the size limits or the schema Deterministic, medium: kick + staff alert in enforce (the matrix of config/modules.lua).
BAS-NE-002 Rate exceeded Heuristic: feeds the score.
BAS-NE-003 ACL refused Heuristic: feeds the score.
BAS-NE-004 Context refused Heuristic, unverified: logged only, no score and no alert, until the game reads it relies on are captured in game. The event is still refused.
BAS-NE-005 SDK data missing or invalid, or a refused hello Heuristic, weight 2, tied to the sdk list: observe and out of the score until you calibrate it. Never blocks the call (sdk.md).

BAS-NE-002 to BAS-NE-005 are sent to the scoring once per player, event and window (netevents.violationReportMs, 10 s by default); every refused call stays refused. So a player who clicks a rate-limited button twelve times in a second is refused twelve times and scored once. BAS-NE-001 is reported on every call.

Everything goes through the usual decision: nothing is applied in observe, a player with bastrel.bypass.netevents gets no sanction. A score alone never bans.

onViolation replaces the action of BAS-NE-001 for that event only: 'flag' = staff alert only, 'drop' = kick + alert, 'ban' = ban + alert. Without it, BAS-NE-001 follows the matrix (kick + alert). It only applies to BAS-NE-001; on BAS-NE-002 to 004 it is ignored (a warning netevents.onviolation_ignored is logged once per event). A heuristic never bans.

onViolation = 'ban' is available to every resource that registers an event. It only bans on BAS-NE-001, a schema violation of an event whose schema you wrote, but a schema that is too strict will ban honest players: start with 'flag' or 'drop', and test your schema with real clients first.

-- config/modules.lua, block netevents: declare the code once, with its score weight
ownerCodes = { ['BAS-OWN-001'] = 15 },
exports.bastrel:report(src, 'BAS-OWN-001', { pointId = pointId })
  • Only codes you declared in netevents.ownerCodes are accepted (BAS-OWN- and three digits). They are always a heuristic of severity low: they feed the score and its thresholds, never a ban on their own. Bastrel adds them to its own code table at startup, so logs and Discord treat them like its codes.
  • Every code shipped by Bastrel (BAS-GE-001, …) is refused: otherwise a resource of yours, even a compromised one, could trigger a ban with it.
  • The third argument is kept in the evidence, with the name of the calling resource. A fourth argument true (“deterministic”) is ignored, with one warning.
  • report answers true or false. A refusal is logged (netevents.report_refused, once per resource and reason in netevents.logWindowMs). netevents.reportAllowedResources (a list of resource names) limits who may report; empty = any server resource.

config/modules.lua, block netevents:

Key Default Meaning
limits maxArgs = 32, maxDepth = 6, maxKeys = 128, maxStringLen = 4096, maxTotalBytes = 65536 Size bounds of a payload. Generous: a payload past one of them is BAS-NE-001, which kicks, so raise them for an event that legitimately sends more (opts.limits).
requireSdkDefault true Default of requireSdk.
ownerCodes {} Your codes: ['BAS-OWN-001'] = weight.
reportAllowedResources {} Who may call report.
logWindowMs 60000 A refusal of report and a failing handler are logged once per window.
violationReportMs 10000 BAS-NE-002 to BAS-NE-005 reach the scoring once per player, event and window (ms, 1000 to 3600000).

An invalid value takes its default, with a warning; the valid ones are kept. The exception is reportAllowedResources: an invalid list refuses every resource (it never means “anyone”). The weights of BAS-NE-001 to 005 are in scoring.weights (20, 10, 15, 10, 2). The sdk block is described in sdk.md.

  • This protects your events. A cheat that calls your event with a valid payload still gets through: that is why you decide amounts, prices and items on the server and check distances and rights in the handler.
  • The client SDK (sdk.md) only slows down a cheat that creates its own runtime; it cannot stop code injected into a resource that is already running.
  • A local TriggerEvent of a registered event (from another server script) is meant to be ignored, because its source is not a tracked player, but the value of source for a local trigger is not documented: do not rely on it, and call your own functions directly instead.