fix(rpc): prevent client id spoofing

This commit is contained in:
2026-09-28 02:05:40 +01:00
parent b48d3f5240
commit 6247b7d781
6 changed files with 100 additions and 91 deletions
+38 -6
View File
@@ -4,13 +4,14 @@ import {
notRegisteredMessage,
RPCError,
} from '../utils/errors'
import { generateUUID } from '../utils/funcs'
import { generateUUID, isRPCState, parse } from '../utils/funcs'
import { Pending } from '../utils/pending'
import {
type RPCConfig,
type RPCEnvironment,
RPCErrors,
type RPCState,
type RPCStateRaw,
} from '../utils/types'
export class RPCInstanceBase {
@@ -36,7 +37,7 @@ export class RPCInstanceBase {
emitter.on(event, cb)
return this
}
/** Unregisters `cb` for `event` on `emitter`; `method` is only for logs */
protected unlisten(emitter: Emitter, method: string, event: string): this {
this.log(`${method} ${event}`)
@@ -76,15 +77,46 @@ export class RPCInstanceBase {
return this._emitterLocal.emit<R>(event, ...args)
}
/** Settles the call waiting for `response`; ignores late or unexpected ones */
protected settle(response: RPCState): void {
/**
* Settles the call waiting for `response`; ignores late or unexpected ones.
*
* @param peer - who sent the response (server: `source`)
*/
protected settle(response: RPCState, peer?: number): void {
const found = response.error
? this._pending.reject(response.uuid, RPCError.fromResponse(response))
: this._pending.resolve(response.uuid, response.data?.[0])
? this._pending.reject(
response.uuid,
RPCError.fromResponse(response),
peer,
)
: this._pending.resolve(response.uuid, response.data?.[0], peer)
if (!found) this.logIgnored(response)
}
/**
* Parses and validates an incoming payload. Anything else sent on the RPC
* channels (broken JSON, other shapes) is dropped: `null`.
*/
protected accept(input: RPCStateRaw | unknown): RPCState | null {
const payload =
typeof input === 'string'
? parse(input as RPCStateRaw)
: isRPCState(input)
? input
: null
if (!payload) {
this.log(`dropped invalid payload ${String(input).slice(0, 200)}`)
return null
}
this.log(
`accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`,
)
return payload
}
/**
* Runs the listener for `request` and builds the response to send back.
* Never throws: a missing listener or a thrown error ends up in `error`.
+2 -9
View File
@@ -86,7 +86,6 @@ export class RPCInstanceClient extends RPCInstanceBase {
return this.dispatch(this._emitterWeb, payload)
}
if (payload.calledTo === 'server') {
payload.player = GetPlayerServerId(PlayerId())
emitNet(RPCEvents.LISTENER_WEB, stringify(payload))
return this._pending.wait<RPCState>(payload)
@@ -100,7 +99,6 @@ export class RPCInstanceClient extends RPCInstanceBase {
return { status: 'ok' }
}
if (payload.calledTo === 'server') {
payload.player = GetPlayerServerId(PlayerId())
emitNet(RPCEvents.LISTENER_WEB, stringify(payload))
return { status: 'ok' }
@@ -135,12 +133,7 @@ export class RPCInstanceClient extends RPCInstanceBase {
Arguments extends Parameters<s.RPCEvents_ClientServer[EventName]>,
Response extends ReturnType<s.RPCEvents_ClientServer[EventName]>,
>(eventName: EventName, ...args: Arguments): Promise<Awaited<Response>> {
const payload = this.request(
eventName,
'server',
args,
GetPlayerServerId(PlayerId()),
)
const payload = this.request(eventName, 'server', args, null)
emitNet(RPCEvents.LISTENER_CLIENT, stringify(payload))
@@ -173,7 +166,7 @@ export class RPCInstanceClient extends RPCInstanceBase {
Arguments extends Parameters<s.RPCEvents_ClientWebview[EventName]>,
Response extends ReturnType<s.RPCEvents_ClientWebview[EventName]>,
>(eventName: EventName, ...args: Arguments): Promise<Awaited<Response>> {
const payload = this.request(eventName, 'webview', args, PlayerId())
const payload = this.request(eventName, 'webview', args, null)
this._sendWebMessage({
origin: RPCEvents.LISTENER_CLIENT,
+32 -59
View File
@@ -25,71 +25,44 @@ export class RPCInstanceServer extends RPCInstanceBase {
console.log('[RPC] Initialized Server')
onNet(RPCEvents.LISTENER_CLIENT, this._handleClient.bind(this))
onNet(RPCEvents.LISTENER_WEB, this._handleWeb.bind(this))
// `source` must be read synchronously, before any await
onNet(RPCEvents.LISTENER_CLIENT, (raw: RPCStateRaw) =>
this._handle(raw, source, 'client', this._emitterClient),
)
onNet(RPCEvents.LISTENER_WEB, (raw: RPCStateRaw) =>
this._handle(raw, source, 'webview', this._emitterWeb),
)
}
// ===== HANDLERS =====
private async _handleClient(payloadRaw: RPCStateRaw) {
/**
* Handles a payload from a client or its webview.
*
* @param player - FiveM `source` of the net event: the only trusted player id,
* whatever the payload claims
*/
private async _handle(
payloadRaw: RPCStateRaw,
player: number,
from: 'client' | 'webview',
emitter: Emitter,
) {
const payload = this.accept(payloadRaw)
if (!payload) return
if (!payload || payload.calledFrom !== from) return
if (payload.calledFrom === 'client') {
if (payload.type === 'event') {
if (payload.player === null || payload.player === -1) {
// nobody to reply to, the caller times out
console.error(
new RPCError(
RPCErrors.NO_PLAYER,
`${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`,
),
)
return
}
const response = await this.dispatch(
this._emitterClient,
payload,
payload.player,
)
emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response))
}
if (payload.type === 'response') {
this.settle(payload)
}
// not sent by a player, e.g. a server-side trigger of the RPC channel
if (!(player > 0)) {
this.log(`dropped ${payload.event}: no player source`)
return
}
}
payload.player = player
private async _handleWeb(payloadRaw: RPCStateRaw) {
const payload = this.accept(payloadRaw)
if (!payload) return
if (payload.calledFrom === 'webview') {
if (payload.type === 'event') {
if (payload.player === null || payload.player === -1) {
// nobody to reply to, the caller times out
console.error(
new RPCError(
RPCErrors.NO_PLAYER,
`${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`,
),
)
return
}
const response = await this.dispatch(
this._emitterWeb,
payload,
payload.player,
)
emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response))
}
if (payload.type === 'response') {
this.settle(payload)
}
if (payload.type === 'event') {
const response = await this.dispatch(emitter, payload, player)
emitNet(RPCEvents.LISTENER_SERVER, player, stringify(response))
} else {
this.settle(payload, player)
}
}
@@ -128,7 +101,7 @@ export class RPCInstanceServer extends RPCInstanceBase {
emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload))
return this._pending.wait<Awaited<Response>>(payload)
return this._pending.wait<Awaited<Response>>(payload, player)
}
public async emitClientEveryone<
@@ -175,7 +148,7 @@ export class RPCInstanceServer extends RPCInstanceBase {
emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload))
return this._pending.wait<Awaited<Response>>(payload)
return this._pending.wait<Awaited<Response>>(payload, player)
}
// ===== SELF =====