From 598b5ece49488f46626011a1f3927426b2cace53 Mon Sep 17 00:00:00 2001 From: Flussen Date: Fri, 1 May 2026 17:43:05 +0200 Subject: [PATCH] fix: resolve security bypass and auth rejection for players with clientID 0 in @Guard and @Throttle decorators --- RELEASE.md | 13 +++++------ package.json | 2 +- src/runtime/server/decorators/guard.ts | 2 +- src/runtime/server/decorators/throttle.ts | 2 +- tests/unit/server/decorators/guard.test.ts | 22 +++++++++++++++++++ tests/unit/server/decorators/throttle.test.ts | 17 ++++++++++++++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index e240632..b3c90d9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,9 +1,8 @@ -## OpenCore Framework v1.0.10 - -### Added -- Added authoritative CORE exports to link and unlink player accounts from remote resources. -- Added debug logs for remote player session mutations delegated from `RESOURCE` to `CORE`. +## OpenCore Framework v1.0.13 ### Fixed -- Fixed `RESOURCE` player session mutations so `player.linkAccount()`, `player.unlinkAccount()`, `player.setMeta()` and state changes propagate to CORE. -- Fixed secure `@OnNet` and `@OnRPC` handlers being blocked in sibling resources after successful authentication performed from a `RESOURCE` auth module. \ No newline at end of file +- Fixed `@Guard` rejecting valid players with `clientID: 0`, which affected the first RageMP player connected to a fresh server. +- Fixed `@Throttle` skipping rate-limit enforcement for valid players with `clientID: 0`, preventing an unthrottled first-player security bypass on RageMP. + +### Tests +- Added regression coverage for `@Guard` and `@Throttle` with `clientID: 0`. diff --git a/package.json b/package.json index 7d915a7..f88f873 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-core/framework", - "version": "1.0.12", + "version": "1.0.13", "description": "Secure, event-driven TypeScript Framework & Runtime engine for CitizenFX (Cfx).", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/runtime/server/decorators/guard.ts b/src/runtime/server/decorators/guard.ts index b77514c..00f038d 100644 --- a/src/runtime/server/decorators/guard.ts +++ b/src/runtime/server/decorators/guard.ts @@ -69,7 +69,7 @@ export function Guard(options: GuardOptions) { const originalMethod = descriptor.value descriptor.value = async function (...args: any[]) { const player = args[0] as Player - if (!player || !player.clientID) { + if (!player || typeof player.clientID !== 'number') { loggers.security.warn(`@Guard misuse: First argument is not a Player`, { method: propertyKey, targetClass: target.constructor?.name, diff --git a/src/runtime/server/decorators/throttle.ts b/src/runtime/server/decorators/throttle.ts index 66304b6..f2e49f2 100644 --- a/src/runtime/server/decorators/throttle.ts +++ b/src/runtime/server/decorators/throttle.ts @@ -85,7 +85,7 @@ export function Throttle(optionsOrLimit: number | ThrottleOptions, windowMs?: nu descriptor.value = async function (...args: any[]) { const player = args[0] as Player - if (player?.clientID) { + if (player && typeof player.clientID === 'number') { const service = container.resolve(RateLimiterService) const key = `${player.clientID}:${target.constructor.name}:${propertyKey}` diff --git a/tests/unit/server/decorators/guard.test.ts b/tests/unit/server/decorators/guard.test.ts index 8941a4e..387ff67 100644 --- a/tests/unit/server/decorators/guard.test.ts +++ b/tests/unit/server/decorators/guard.test.ts @@ -223,6 +223,28 @@ describe('@Guard decorator', () => { expect(result).toBe('action-result') }) + it('should accept player with clientID 0', async () => { + const mockPrincipal = { + enforce: vi.fn().mockResolvedValue(undefined), + } + container.registerInstance(Authorization as any, mockPrincipal as any) + + class TestController { + @Guard({ rank: 1 }) + protectedAction(_player: MockPlayer) { + return 'action-result' + } + } + + const player: MockPlayer = { clientID: 0, name: 'FirstPlayer' } + const instance = new TestController() + + const result = await instance.protectedAction.call(instance, player) + + expect(result).toBe('action-result') + expect(mockPrincipal.enforce).toHaveBeenCalledWith(player, { rank: 1 }) + }) + it('should pass all arguments to original method', async () => { class TestController { @Guard({ rank: 1 }) diff --git a/tests/unit/server/decorators/throttle.test.ts b/tests/unit/server/decorators/throttle.test.ts index 9129c92..ceedc8f 100644 --- a/tests/unit/server/decorators/throttle.test.ts +++ b/tests/unit/server/decorators/throttle.test.ts @@ -56,6 +56,23 @@ describe('@Throttle decorator', () => { await expect(instance.action.call(instance, player)).rejects.toThrow(SecurityError) }) + it('should rate limit player with clientID 0', async () => { + class TestController { + @Throttle(1, 5000) + action(_player: MockPlayer) { + return 'executed' + } + } + + const player: MockPlayer = { clientID: 0, name: 'FirstPlayer' } + const instance = new TestController() + + const result = await instance.action.call(instance, player) + + expect(result).toBe('executed') + await expect(instance.action.call(instance, player)).rejects.toThrow(SecurityError) + }) + it('should use default window of 1000ms when only limit provided', async () => { class TestController { @Throttle(2)