From 2dd054ea8ba175a0f615599ccdd72a64bd83cff6 Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:33 +0200 Subject: [PATCH 1/6] feat(rpc): token bucket for every app call, optional per-endpoint cooldown --- server/computers/ratelimit.lua | 16 ++++++++++++++++ server/computers/rpc.lua | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/server/computers/ratelimit.lua b/server/computers/ratelimit.lua index f8b4e97..80ba595 100644 --- a/server/computers/ratelimit.lua +++ b/server/computers/ratelimit.lua @@ -19,6 +19,21 @@ function ratelimit.take(src, key, seconds) return true end +local buckets = {} + +function ratelimit.bucket(src, key, burst, perSecond) + local now = GetGameTimer() + local mine = buckets[src] + if not mine then mine = {}; buckets[src] = mine end + local b = mine[key] + if not b then b = { tokens = burst, at = now }; mine[key] = b end + b.tokens = math.min(burst, b.tokens + (now - b.at) / 1000 * perSecond) + b.at = now + if b.tokens < 1 then return false end + b.tokens = b.tokens - 1 + return true +end + ---Seconds left on `src`'s cooldown for `key` (0 when free). ---@param src integer player server id ---@param key string action key @@ -33,6 +48,7 @@ end ---@param src integer player server id function ratelimit.clear(src) until_[src] = nil + buckets[src] = nil end return ratelimit diff --git a/server/computers/rpc.lua b/server/computers/rpc.lua index 19b979d..54f74e8 100644 --- a/server/computers/rpc.lua +++ b/server/computers/rpc.lua @@ -3,6 +3,8 @@ local log = require 'shared.log' ---@type table Session gate (server.computers.session). local session = require 'server.computers.session' +local ratelimit = require 'server.computers.ratelimit' + ---@type table RPC module; the table returned at end of file. local rpc = {} @@ -15,8 +17,8 @@ local handlers = {} ---@param name string endpoint, e.g. 'mail:send' ---@param app string|nil app the caller must have here (nil = any signed-in session) ---@param fn fun(src: integer, s: ComputerSession, payload: table): table handler returning { ok, data?, error? } -function rpc.register(name, app, fn) - handlers[name] = { app = app, fn = fn } +function rpc.register(name, app, fn, limit) + handlers[name] = { app = app, fn = fn, limit = limit } end ---Shorthand success reply. @@ -43,8 +45,11 @@ lib.callback.register('sd-computers:server:rpc', function(src, name, payload) local h = type(name) == 'string' and handlers[name] if not h then return rpc.fail('err.invalid') end + if not ratelimit.bucket(src, 'rpc', 40, 15) then return rpc.fail('err.slow_down') end + local s, err = session.guard(src, h.app) if not s then return { ok = false, error = err } end + if h.limit and not ratelimit.take(src, 'rpc:' .. name, h.limit) then return rpc.fail('err.slow_down') end local ok, res = pcall(h.fn, src, s, type(payload) == 'table' and payload or {}) if not ok then @@ -54,4 +59,8 @@ lib.callback.register('sd-computers:server:rpc', function(src, name, payload) return res or rpc.ok() end) +AddEventHandler('playerDropped', function() + ratelimit.clear(source) +end) + return rpc From b6511786cae27a9fc62834486b3a97d40df916ee Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:34 +0200 Subject: [PATCH 2/6] fix(boss): refund the society when a withdrawal cannot be paid out, cool down boss actions --- bridge/server/money.lua | 6 ++++-- server/computers/boss.lua | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/bridge/server/money.lua b/bridge/server/money.lua index 630f790..86031d8 100644 --- a/bridge/server/money.lua +++ b/bridge/server/money.lua @@ -36,17 +36,19 @@ function money.add(source, moneyType, amount, reason) local p = player_mod.get(source) if not p then dprint('add: player not found for src=%s — aborting', tostring(source)) - return + return false end if framework.name == 'qb' then dprint('add: calling QBCore AddMoney type=%s amount=%s', tostring(converted), tostring(amount)) - p.Functions.AddMoney(converted, amount, reason) + return p.Functions.AddMoney(converted, amount, reason) ~= false elseif framework.name == 'esx' then dprint('add: calling ESX addAccountMoney type=%s amount=%s', tostring(converted), tostring(amount)) p.addAccountMoney(converted, amount) + return true else dprint('add: unsupported framework %s — no money added', tostring(framework.name)) + return false end end diff --git a/server/computers/boss.lua b/server/computers/boss.lua index a5716d7..adca36e 100644 --- a/server/computers/boss.lua +++ b/server/computers/boss.lua @@ -128,7 +128,7 @@ rpc.register('boss:setGrade', 'boss', function(src, s, payload) local target = employees.sourceOf(cid) if target then notify.to(target, { title = def.label, description = locale('boss.notify_grade', def.grades[grade].name), type = 'inform' }) end return rpc.ok() -end) +end, 1) ---Fire someone below your grade (online or offline). ---@param payload { cid: string } @@ -151,7 +151,7 @@ rpc.register('boss:fire', 'boss', function(src, s, payload) audit(s, logs.EVENT.BOSS_FIRE, locale('logs.boss_fire', s.name, name, def and def.label or s.bossJob), { cid = cid }) if target then notify.to(target, { title = def and def.label or s.bossJob, description = locale('boss.notify_fired'), type = 'error' }) end return rpc.ok() -end) +end, 1) ---Players near the COMPUTER who could be hired (not yourself, not already in this job). rpc.register('boss:nearby', 'boss', function(src, s) @@ -213,7 +213,7 @@ rpc.register('boss:hire', 'boss', function(src, s, payload) audit(s, logs.EVENT.BOSS_HIRE, locale('logs.boss_hire', s.name, name, def.label), { cid = player.getIdentifier(target), byId = payload.byId == true or nil }) notify.to(target, { title = def.label, description = locale('boss.notify_hired', def.label), type = 'success' }) return rpc.ok({ name = name }) -end) +end, 1) ---Move the boss's own money into the society. Debit first (it's the step that can fail on ---insufficient funds); if the society credit then fails, the debit is refunded. @@ -236,7 +236,7 @@ rpc.register('boss:deposit', 'boss', function(src, s, payload) end audit(s, logs.EVENT.BOSS_DEPOSIT, locale('logs.boss_deposit', s.name, amount, label), { amount = amount, from = from }) return rpc.ok({ balance = society.balance(s.bossJob) }) -end) +end, 1) ---Move society money to the boss. The society debit is refused outright when it can't cover it. ---@param payload { amount: number } @@ -248,9 +248,12 @@ rpc.register('boss:withdraw', 'boss', function(src, s, payload) local def = employees.job(s.bossJob) local label = def and def.label or s.bossJob if not society.withdraw(s.bossJob, amount, s.name, label) then return rpc.fail('boss.err_society_broke') end - money.add(src, B.WithdrawTo or 'bank', amount, 'sd-computers-society-withdraw') + if not money.add(src, B.WithdrawTo or 'bank', amount, 'sd-computers-society-withdraw') then + society.deposit(s.bossJob, amount, s.name, label) + return rpc.fail('boss.err_failed', '?') + end audit(s, logs.EVENT.BOSS_WITHDRAW, locale('logs.boss_withdraw', s.name, amount, label), { amount = amount }) return rpc.ok({ balance = society.balance(s.bossJob) }) -end) +end, 1) return {} From 7e24f8e5b33006502adb7a3bcd0496b6ff60d599 Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:34 +0200 Subject: [PATCH 3/6] fix(appdata): bounded wait for the write lock instead of an endless spin --- server/computers/appdata.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/computers/appdata.lua b/server/computers/appdata.lua index 93060ba..3e5258b 100644 --- a/server/computers/appdata.lua +++ b/server/computers/appdata.lua @@ -115,7 +115,12 @@ end ---@return string|nil why locale key function appdata.set(app, owner, key, value) local lock = app .. '|' .. owner - while writing[lock] do Wait(0) end + local waited = 0 + while writing[lock] do + if waited >= 500 then return false, 'err.internal' end + waited = waited + 1 + Wait(10) + end writing[lock] = true local ok, done, why = pcall(set, app, owner, key, value) writing[lock] = nil From 72f27c35c836faf7332d4c8eb9685ed938211767 Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:34 +0200 Subject: [PATCH 4/6] fix(session): constant-time PIN compare, prune stale PIN lockout state --- server/computers/session.lua | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/server/computers/session.lua b/server/computers/session.lua index 268e745..32fb467 100644 --- a/server/computers/session.lua +++ b/server/computers/session.lua @@ -297,6 +297,14 @@ function session.actor(s) return { cid = s.citizenid, name = s.name } end +local function sameText(a, b) + local diff = #a ~ #b + for i = 1, math.max(#a, #b) do + diff = diff | ((a:byte(i) or 0) ~ (b:byte(i) or 0)) + end + return diff == 0 +end + ---Seconds left on a player's PIN lockout for a computer (0 = not locked out). ---@param citizenid string ---@param id string computer id @@ -409,9 +417,10 @@ function session.signIn(src, pin) local left = lockoutLeft(s.citizenid, s.def.id) if left > 0 then return { ok = false, error = locale('err.locked_out', left), lockedFor = left } end - if tostring(pin or '') ~= tostring(expected) then + if not sameText(tostring(pin or ''), tostring(expected)) then local st = pinState[key] or { fails = 0 } st.fails = st.fails + 1 + st.at = os.time() pinState[key] = st logs.write(s.instance, { level = 'failure', source = 'Security', eventId = logs.EVENT.LOGON_FAILED, @@ -649,4 +658,14 @@ AddEventHandler('playerDropped', function() session.close(source) end) +CreateThread(function() + while true do + Wait(10 * 60 * 1000) + local now = os.time() + for key, st in pairs(pinState) do + if now - math.max(st.lockedUntil or 0, st.at or 0) > 600 then pinState[key] = nil end + end + end +end) + return session From 3de8b983c1e95042df57c34b4a859827c79fc824 Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:34 +0200 Subject: [PATCH 5/6] fix(mirror): cap what one session relays to all its watchers --- server/computers/mirror.lua | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/server/computers/mirror.lua b/server/computers/mirror.lua index 5241bfd..a4714e1 100644 --- a/server/computers/mirror.lua +++ b/server/computers/mirror.lua @@ -49,6 +49,8 @@ local MAX_BATCH = 1024 * 1024 local RATE = 256 * 1024 ---@type integer Bytes: the most a session can have saved up. local BURST = 2 * 1024 * 1024 +local OUT_RATE = 1024 * 1024 +local OUT_BURST = 12 * 1024 * 1024 ---@type integer Bytes: batches up to this go as ordinary events, larger ones as latent events. local SMALL = 4 * 1024 ---@type integer Bytes a second a latent batch is sent to a watcher at. @@ -62,6 +64,7 @@ local watchers = {} local watching = {} ---@type table session holder -> their byte budget. local budget = {} +local relayed = {} ---@type integer Counts sessions, so a client can tell a new one from the one it was watching. local serial = 0 @@ -145,6 +148,17 @@ local function spend(src, size) return true end +local function spendOut(src, size) + local now = GetGameTimer() + local b = relayed[src] + if not b then b = { tokens = OUT_BURST, at = now }; relayed[src] = b end + b.tokens = math.min(OUT_BURST, b.tokens + (now - b.at) / 1000 * OUT_RATE) + b.at = now + if b.tokens < size then return false end + b.tokens = b.tokens - size + return true +end + session.onOpen(function(src, s) if not ENABLED then return end serial = serial + 1 @@ -157,6 +171,7 @@ session.onClose(function(src, s) if not a or a.src ~= src then return end active[s.instance] = nil budget[src] = nil + relayed[src] = nil for w in pairs(watchers[s.instance] or {}) do if watching[w] then watching[w][s.instance] = nil end end @@ -211,7 +226,7 @@ RegisterNetEvent('sd-computers:server:mirror:batch', function(seq, full, blob) local n = type(seq) == 'number' and math.tointeger(seq) or nil if not n or type(blob) ~= 'string' then return end local size = #blob - if size == 0 or size > MAX_BATCH or not spend(src, size) then return end + if size == 0 or size > MAX_BATCH or not spend(src, size) or not spendOut(src, size * count(list)) then return end full = full == true for w in pairs(list) do From c19a7db7cfd4a102498886b5d580484820cf991d Mon Sep 17 00:00:00 2001 From: Heino <132708558+HeinoDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:10:34 +0200 Subject: [PATCH 6/6] refactor(web): type the dev entry point casts instead of using never --- web/src/App.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index ab8eda6..b78fd8f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { HackRun, OpenPayload } from './types'; +import type { AppId, HackRun, OpenPayload } from './types'; import { useNuiEvent } from './hooks/useNuiEvent'; import { fetchNui, isEnvBrowser } from './nui/bridge'; import { mockPayloadFor } from './nui/mock/payload'; @@ -16,6 +16,7 @@ import Computer from './os/Computer'; import LibHack from './os/LibHack'; import Paper, { type PaperPage } from './os/Paper'; import { mockPage } from './nui/mock/print'; +import type { FontKey } from './lib/richdoc'; import { useExtEvents } from './apps/ext/events'; /** computers:mail:received (built-in Mail) - also what a phone mail push is reshaped into. */ @@ -161,7 +162,7 @@ export default function App() { // (on a prop's screen the prop is the monitor: retro leaves its own chassis off, as Lua asks it to) if (query.has('frame')) mockPayload.desktop.monitor = false; if (hash === 'paper') { - setPaper(mockPage(query.get('kind'), query.get('font') as never)); + setPaper(mockPage(query.get('kind'), query.get('font') as FontKey | null)); return; } if (hash === 'lib') { @@ -172,7 +173,7 @@ export default function App() { if (hash) { const os = useOS.getState(); os.setPhase('desktop'); - if (hash !== 'desktop') window.setTimeout(() => os.openApp(hash as never), 50); + if (hash !== 'desktop') window.setTimeout(() => os.openApp(hash as AppId), 50); } // Reopen after "shutting down" so the browser session never ends up on a blank page. const reopen = () => window.setTimeout(() => open(mockPayload), 1200);