Skip to content

Security

A FiveM client is a program on somebody else’s computer. Event loggers and Lua executors are ordinary tools, freely available, and a server that is only safe while the phone’s own page is the thing talking to it is not safe. This is what that means in practice here, and where the rules live.

Everything below was gone over handler by handler, and eleven things came out of it. They are listed at the end, not because the list is interesting but because the shape of it is: four of them were the same mistake in four different places.

Then the same question was asked the other way round — not what a client can send, but what the phone believes about what it is sent — and that is rule six.


1. Who you are comes from the server, never from the message.

Phone.Handler(name, fn) calls fn(src, data, player), where src is the connection and player is looked up from it. No handler takes an identifier, a phone number or a character from the client to decide whose data to touch. A recipient is resolved from a phone number through the database; a sender is always the caller.

The one time this was broken, mic_phone:boot was registered as Phone.Boot itself — and Phone.Boot’s second argument is the record to build a phone from, while a callback hands the handler whatever the client sent as its second argument. Anybody could ask for anybody’s phone by name. server/tests.lua has a test that calls the registered callback with a crafted character and fails if it gets a phone back.

2. A framework’s own events are not doors.

RegisterNetEvent opens an event name to the network for this resource. ESX raises esx:playerLoaded and esx:playerLogout locally, with TriggerEvent, and never opens them — so registering them here as net events made mic_phone accept them from any client, with any player id inside. That walks into Phone.OnDropped: end that player’s call, take them out of their radio, forget their phone.

AddEventHandler for anything a framework raises itself. A linter will tell you the opposite; it is wrong, and following it puts the hole back.

3. Nothing the client sends is passed along as it arrived.

Anything that will be stored, or shown to somebody else, is rebuilt from the fields we know rather than relayed whole: Nearby.Shared for what one phone hands another, attachment() for what a message carries, the request in services:request, the item in citylist:save. A field nobody named does not survive the trip.

4. An address is checked against the hosts this server uses.

Photos.AllowedUrl — https, under 600 characters, and a host from ServerConfig.Media.Hosts. Every picture that reaches a database row or another player’s screen goes through it: the camera, nearby shares, listing photos, message attachments, social posts and profiles.

Without it, a crafted client can put an address of its choosing in front of every player who scrolls past — which is a way of collecting the addresses of everyone on a server — or, since these columns are LONGTEXT, carry a whole picture inside the link itself.

5. Everything has a size and a rate.

DB.Fit(value, width)a string, cut to its column
DB.Json(value, limit)a table, encoded, or nothing if it will not fit
DB.SetJson128 KB per blob; the biggest real one seen is 929 bytes
Phone.TooFast100 calls per 10 seconds, per player
Photos.Chunk3 uploads in flight, 24 MB, swept after a minute of silence
Contacts.Most200
Calls.MostRecents100
MOST_ATTACHMENTS8

The sizes matter more than they look. The database runs in strict mode, so a value one character wider than its column is not trimmed — the insert fails, in the middle of whatever else that handler was doing. In a transfer, that line comes after the money has moved.

6. What another player wrote is text, and the page is built so that it cannot stop being text.

The five above are about what reaches the server. This one is about what reaches the phone, and it is the more dangerous direction: the page that draws somebody else’s profile is the same page that can call every handler as its owner. A tag that runs there can read that player’s messages and move their money. It does not need an executor — it needs a name.

The phone draws itself with template literals, so every value is markup unless something escapes it. escapeHtml in core/app.js is that something, and rpEsc in the city apps is the same function under the local name. Two halves:

Escape at the point it is written. Every interpolation that carries somebody’s words — a name, a handle, a caption, a note, a neighbourhood, an address — goes through one of those two. This was checked by parsing every template literal in html/ rather than by reading them, because there are 2,918 of them.

And do not depend on that. html/index.html carries a Content-Security-Policy with no 'unsafe-inline' in script-src. Nothing in this phone is an inline script or an inline handler — every script is a file — so the policy costs nothing and means that an onerror a page was tricked into writing simply does not run. It is the half that does not rely on the next person to write a render function remembering anything. server/web.js adds the two things a <meta> cannot say: frame-ancestors 'none', because the link to a linked phone is the whole secret and that phone can send money, and nosniff.

tests/verify-injection.js hands each app a string that would be unmistakable if it ever stopped being text, and then searches the rendered page for it.

And what the server sends without being asked. shared/config/*.lua are shared_scripts: every line of them reaches every player’s client and can be read out of the game with an executor. Config.Web held the name this server answers on, the address its QR points at and — twice — its owner’s email address, none of which a client has ever read. They live in shared/server_config.lua now, which fxmanifest.lua lists under server_scripts and which already had a blank example beside it to ship. A config file is not a private place, and the settings that describe this server are the ones to keep out of it.


The careful branch and the one beside it. Four of the eleven were the same mistake: a function that handled the case somebody had in mind and passed the other one through. data: was uploaded and url was not, in four separate places, each written at a different time by somebody who had just been careful three lines above. If you add a branch to any of these, ask what the other branch does.

A limit that is generous is still a limit. The contacts list waits on a query per contact, and an insert takes about a third of a second on the server this was written on: five hundred contacts was nearly three minutes of database held by one request. The number was not wrong because it was large — it was wrong because nobody had multiplied it by anything.

Rules live in one function, not in each handler. Everything in the table above is one place. That is the only way a rule survives the next person adding a handler.

A check that stops early is not a check. Photos.AllowedUrl read the host and stopped at the first slash — and everything after that slash is the part a client writes. https:// + a host we allow + /x.jpg" onerror="… passed it, and the phone put that string into the src of an img for every player who opened the listing. The rule was right; it was asked about a third of the address.


What it let somebody doWhere
End any player’s call and drop their radio, at willesx:playerLogout registered as a net event
Fill the server’s memory until it fell overuploads with no cap and no sweeper
Move money with no record, and be told it faileda note wider than its column, in strict mode
Put any address in another player’s photo librarynearby shares
Put any address in front of every player in the citylisting photos
Put any address in front of everybody in a chatmessage attachments
Put any address in front of everybody in an appsocial posts and profiles
Call any handler as fast as it could sendno rate limit on the in-game path
Store as much as it liked, repeatedlysettings, places, albums, preferences
Add whatever it liked to an outbound requesttime pasted into a URL unescaped
Read any character’s phonemic_phone:boot, fixed earlier

All eleven have a test in server/tests.lua now, up from five.

Two of them are worth separating out. Listing photos and social posts are reached only through Photos.AllowedUrl — so a test of that function says the rule is right and says nothing about whether those handlers still ask it. the handlers that take a photo still ask whether it is one goes in through the door, as a linked phone does, and reads the refusal: with the check taken back out, the crafted address is accepted, the listing is written and broadcast to every phone in the city, the post is written and shown to everybody in the app, and the profile answers with

without the picture that was not an address — got https://<a host we allow>/x.jpg" onerror="alert(1)

which is the string that would have gone into an img. The passing run writes nothing at all; the cleanup beside it is for the failing one, which is the run where it matters.

The framework-event one is the odd test out: there is no way to ask the runtime which event names a resource has opened to the network, so it reads bridge/framework/server.lua and looks for the words. Weaker than the rest, and there anyway — the mistake it guards is one a linter actively recommends.

What it let somebody doWhere
Run code on the phone of everybody who looked at their profilea name, handle, neighbourhood, job or interest pasted into markup
Run code on the phone of everybody who opened a listinga photo address pasted into src, and an address check that allowed a quote
Run code on the phone of anybody reading a bill, a garage or a businessissuers, titles, plates, locations and business details, pasted raw
Have any of the above actually runno content policy on the page at all
Put the phone inside another site’s pageno frame-ancestors from the web door

The first three are one mistake each; the last two are why the first three mattered. All five are covered by tests/verify-injection.js, which checks the page a browser would have built rather than the strings that went into it.

Both tests were watched failing first, with the fix taken back out: the address one names its three missing refusals, and the injection one counts four elements that escaped their attribute — and says nothing about one having run, because by then the policy was also there.