Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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.
- 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`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/decorators/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/decorators/throttle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`

Expand Down
22 changes: 22 additions & 0 deletions tests/unit/server/decorators/guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/server/decorators/throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading