Skip to content

State bags: protected keys

A state bag is a key/value store FiveM keeps on a player (player:<server id>) or on a networked entity (entity:<net id>). By default a client can write its own player state and the state of the entities it owns, and the change is not replicated unless the client asks for it. Never keep anything critical (job, money, rights, handcuffed state) in a bag a client can write.

The real protection: sv_stateBagStrictMode

Section titled “The real protection: sv_stateBagStrictMode”

Put this in server.cfg, before the resources start:

setr sv_stateBagStrictMode true

With it, only the server can modify the state of networked entities and the player state (FiveM build 12739 and later; local entities, localEntity:, are not affected). Bastrel audits it (BAS-CV-011, see convars.md) and never changes it. The catch: it breaks every script of yours that writes a state bag from the client. If you cannot turn it on, use the safety net below.

Bastrel does not read this convar to switch the net off: the value of a convar is no proof that the mode is in force in the running build.

The safety net: protected keys and BAS-SB-001

Section titled “The safety net: protected keys and BAS-SB-001”

BAS-SB-001 means: a key you listed as protected changed, and your server did not announce the change.

It is a clue, never a proof. FiveM’s change handler (AddStateBagChangeHandler) receives (bagName, key, value, reserved, replicated) and no origin: reserved is unused and replicated is only the intent of the set. The write may come from a client, or from one of your own scripts that does not announce its writes yet. For that reason:

  • BAS-SB-001 is unverified for now: it stays in observe whatever your configuration says. The line goes to the log; nobody is sanctioned, nothing is posted to Discord, nothing is added to the score, and nothing is restored. This note goes away when the code has been seen working in game.
  • The lists ship empty, and with both empty the module is inert: no handler is registered and it costs nothing.

In config/modules.lua, block statebags:

statebags = {
protectedKeys = {
player = { 'cuffed', 'isDead' }, -- keys of player:<id> bags
entity = { 'doorsLocked' }, -- keys of entity:<net id> bags
},
},

List a key only after reading your own observe logs. qbx_core accepts hunger, thirst and stress written by the client on purpose: do not list them blindly. A list with one invalid entry (not a string, empty, longer than 64 characters, written twice, more than 64 keys) is refused as a whole, with a warning at start-up.

A script of yours that writes a protected key must go through Bastrel, so the write is not reported:

-- bagName as the change handler gives it: 'player:<server id>' or 'entity:<net id>'
local ok, reason = exports.bastrel:setProtectedState('player:' .. source, 'cuffed', true)

Bastrel remembers the value for announceTtlMs, then writes it (Player(id).state:set(key, value, true), replicated like a server write). It answers true, or false and a reason: bad_args, bad_value (only booleans, finite numbers, strings and tables), no_caller, resource_refused, unknown_bag, not_protected (the key is not in the list for that kind of bag), write_failed. The calling resource is checked like the grants of grants.md: when grants.allowedResources is not empty it must be listed there.

Your write always happens. If Bastrel’s memory of announcements is full (maxAnnounced, a budget of its own that a client cannot fill), the value is still written; only its announcement is not remembered, so the handler’s echo of that write may be reported, and one capacity line per window says so. Announcements that expired are dropped before they are counted.

A table you announce is recognised by a bounded deep comparison (deepMaxNodes, deepMaxDepth): a table with more nodes or levels than that never matches, so its own write is reported as BAS-SB-001. Keep protected values small, or raise the bounds.

One log line per bag, key and reportMs window, for the player concerned (for an entity bag, the current network owner of the entity, which is not necessarily who wrote; an entity made by one of your server scripts has no player owner and is never attributed). It carries the bag, the kind, the key, the type of the value, the value (cut at valueMaxChars characters, never raw and unbounded), replicated as the handler reported it, and the type of the previous value.

When the owner of an entity bag cannot be found, or is not a tracked player, nothing is attributed: only an unattributed line is written.

config/modules.lua, block statebags (an invalid value takes its default, with a warning):

Field Default Meaning
protectedKeys.player, protectedKeys.entity empty Protected keys per kind of bag
announceTtlMs 2000 How long an announced value is expected back (500 to 60000)
reportMs 10000 One finding per bag and key in this window (1000 to 3600000)
maxPending 8 Announced values waiting per bag and key (1 to 64)
maxTracked 512 Report windows held in memory (16 to 10000)
maxAnnounced 256 Announcements held in memory, a budget of its own (16 to 10000)
maxUntracked 32 When maxTracked is full: findings still reported per reportMs window (1 to 1000)
valueMaxChars 200 Characters of the value or of an error text kept in the journal (16 to 2000)
deepMaxNodes, deepMaxDepth 256, 8 Bounds of the comparison of an announced table (16 to 10000, 1 to 32)
  • No origin: a write of one of your scripts that does not announce itself is reported against the player of the bag. That is why the lists are empty and the code is observe only.
  • There is no restoration in v1: BAS-SB-001 only detects and logs, it never writes back to a state bag, in any mode. Putting the old value back is a planned follow-up and a condition for lifting the unverified flag.
  • The built-in rate limiters of FiveM (stateBag, stateBagFlood, stateBagSize) act before Bastrel; what a tripped limiter does to a write is not documented.
  • localEntity: bags and global state are not covered.