Skip to content

Commit ef7e240

Browse files
authored
Merge pull request #405 from eulami/feat/rate-limit-multiplayer-notifications
Add WebSocket rate limits and payload size limits (#338)
2 parents 9e5b39b + a466eb1 commit ef7e240

14 files changed

Lines changed: 791 additions & 218 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SetMetadata } from '@nestjs/common'
2+
3+
export const RATE_LIMIT_KEY = 'rateLimit'
4+
5+
export interface RateLimitOptions {
6+
/** Maximum number of requests allowed within the time window */
7+
limit: number
8+
/** Time window in milliseconds */
9+
windowMs: number
10+
/** Optional key function to extract the client identifier (defaults to IP) */
11+
keyPrefix?: string
12+
}
13+
14+
/**
15+
* Apply a rate limit to a route handler.
16+
*
17+
* Usage:
18+
* @RateLimit({ limit: 5, windowMs: 60_000 })
19+
* @Post('join')
20+
* async joinQueue() { ... }
21+
*/
22+
export const RateLimit = (options: RateLimitOptions) =>
23+
SetMetadata(RATE_LIMIT_KEY, options)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { WsRateLimitOptions } from '../guards/ws-rate-limit.guard'
2+
3+
const WS_RATE_LIMIT_KEY = 'wsRateLimit'
4+
5+
/**
6+
* Apply a per-handler WebSocket rate limit.
7+
*
8+
* Usage:
9+
* @WsRateLimit({ limit: 10, windowMs: 60_000 })
10+
* @SubscribeMessage('joinQueue')
11+
* handleJoinQueue(@MessageBody() data) { ... }
12+
*/
13+
export const WsRateLimit = (options: WsRateLimitOptions) =>
14+
(target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => {
15+
if (propertyKey && descriptor) {
16+
Reflect.defineMetadata(WS_RATE_LIMIT_KEY, options, descriptor.value)
17+
} else {
18+
Reflect.defineMetadata(WS_RATE_LIMIT_KEY, options, target)
19+
}
20+
return descriptor ?? target
21+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
HttpException,
6+
HttpStatus,
7+
Logger,
8+
} from '@nestjs/common'
9+
import { Reflector } from '@nestjs/core'
10+
import { RATE_LIMIT_KEY, RateLimitOptions } from '../decorators/rate-limit.decorator'
11+
12+
interface RequestRecord {
13+
count: number
14+
resetTime: number
15+
}
16+
17+
@Injectable()
18+
export class RateLimitGuard implements CanActivate {
19+
private readonly logger = new Logger(RateLimitGuard.name)
20+
21+
/** Per-key request records keyed by `${routeKey}::${clientKey}` */
22+
private readonly hits = new Map<string, RequestRecord>()
23+
24+
/** Periodic cleanup timer (10 minute interval) */
25+
private readonly cleanupInterval: ReturnType<typeof setInterval>
26+
27+
constructor(private readonly reflector: Reflector) {
28+
this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000)
29+
}
30+
31+
canActivate(context: ExecutionContext): boolean {
32+
const options = this.reflector.getAllAndOverride<RateLimitOptions>(
33+
RATE_LIMIT_KEY,
34+
[context.getHandler(), context.getClass()],
35+
)
36+
37+
if (!options) {
38+
return true // No rate limit configured — allow the request
39+
}
40+
41+
const request = context.switchToHttp().getRequest()
42+
const clientKey = this.extractClientKey(request)
43+
const routeKey = this.getRouteKey(context)
44+
const mapKey = `${routeKey}::${clientKey}`
45+
46+
const now = Date.now()
47+
const record = this.hits.get(mapKey)
48+
49+
if (!record || now > record.resetTime) {
50+
// First request in window or window expired — start a new window
51+
this.hits.set(mapKey, {
52+
count: 1,
53+
resetTime: now + options.windowMs,
54+
})
55+
return true
56+
}
57+
58+
if (record.count >= options.limit) {
59+
const retryAfter = Math.ceil((record.resetTime - now) / 1000)
60+
this.logger.warn(
61+
`Rate limit exceeded for ${clientKey} on ${routeKey} ` +
62+
`(${record.count}/${options.limit} in ${options.windowMs / 1000}s)`,
63+
)
64+
throw new HttpException(
65+
{
66+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
67+
message: 'Rate limit exceeded. Please try again later.',
68+
error: 'Too Many Requests',
69+
retryAfter,
70+
},
71+
HttpStatus.TOO_MANY_REQUESTS,
72+
)
73+
}
74+
75+
record.count++
76+
return true
77+
}
78+
79+
/**
80+
* Extract a client identifier from the request.
81+
* Uses a custom header (X-Forwarded-For), then falls back to remote IP.
82+
*/
83+
private extractClientKey(request: Record<string, unknown>): string {
84+
const headers = request.headers as Record<string, string | string[]> | undefined
85+
if (headers) {
86+
const forwarded = headers['x-forwarded-for']
87+
if (forwarded) {
88+
return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0].trim()
89+
}
90+
}
91+
return (request.ip as string) || 'unknown'
92+
}
93+
94+
/** Build a unique key for the route handler. */
95+
private getRouteKey(context: ExecutionContext): string {
96+
const handler = context.getHandler()
97+
const className = context.getClass()?.name || 'Unknown'
98+
return `${className}.${handler.name}`
99+
}
100+
101+
/** Remove expired entries to prevent unbounded memory growth. */
102+
private cleanup(): void {
103+
const now = Date.now()
104+
let cleaned = 0
105+
for (const [key, record] of this.hits) {
106+
if (now > record.resetTime) {
107+
this.hits.delete(key)
108+
cleaned++
109+
}
110+
}
111+
if (cleaned > 0) {
112+
this.logger.debug(`Rate limit cleanup: removed ${cleaned} expired entries`)
113+
}
114+
}
115+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Logger } from '@nestjs/common'
2+
import type { CanActivate, ExecutionContext } from '@nestjs/common'
3+
import type { Socket } from 'socket.io'
4+
5+
export interface WsRateLimitOptions {
6+
/** Maximum messages allowed within the time window */
7+
limit: number
8+
/** Time window in milliseconds */
9+
windowMs: number
10+
}
11+
12+
interface WsRequestRecord {
13+
count: number
14+
resetTime: number
15+
}
16+
17+
/**
18+
* Rate-limit guard for WebSocket event handlers.
19+
*
20+
* Attach via @UseGuards(WsRateLimitGuard) on the gateway or individual
21+
* @SubscribeMessage handlers. Configure per-handler limits through the
22+
* metadata key 'wsRateLimit' set by the @WsRateLimit() decorator.
23+
*
24+
* Defaults (when no metadata is present): 60 messages / 60 s.
25+
*/
26+
export class WsRateLimitGuard implements CanActivate {
27+
private readonly logger = new Logger(WsRateLimitGuard.name)
28+
private readonly hits = new Map<string, WsRequestRecord>()
29+
30+
constructor() {
31+
// Periodic cleanup every 5 minutes
32+
setInterval(() => this.cleanup(), 5 * 60 * 1000)
33+
}
34+
35+
canActivate(context: ExecutionContext): boolean {
36+
const client: Socket = context.switchToWs().getClient()
37+
const data: unknown = context.switchToWs().getData()
38+
const handler = context.getHandler()
39+
const className = context.getClass()?.name || 'Unknown'
40+
41+
// Read per-handler limit from metadata (set by @WsRateLimit decorator)
42+
const metaKey = 'wsRateLimit'
43+
const options: WsRateLimitOptions =
44+
Reflect.getMetadata(metaKey, handler) ||
45+
Reflect.getMetadata(metaKey, className, handler.name) || {
46+
limit: 60,
47+
windowMs: 60_000,
48+
}
49+
50+
const clientId = this.getClientId(client)
51+
const routeKey = `${className}.${handler.name}`
52+
const mapKey = `${routeKey}::${clientId}`
53+
54+
const now = Date.now()
55+
const record = this.hits.get(mapKey)
56+
57+
if (!record || now > record.resetTime) {
58+
this.hits.set(mapKey, { count: 1, resetTime: now + options.windowMs })
59+
return true
60+
}
61+
62+
if (record.count >= options.limit) {
63+
const retryAfter = Math.ceil((record.resetTime - now) / 1000)
64+
this.logger.warn(
65+
`WS rate limit exceeded for ${clientId} on ${routeKey} ` +
66+
`(${record.count}/${options.limit} in ${options.windowMs / 1000}s)`,
67+
)
68+
// Emit error event back to the client
69+
client.emit('error', {
70+
code: 'RATE_LIMIT_EXCEEDED',
71+
message: 'Too many requests. Please slow down.',
72+
retryAfter,
73+
})
74+
return false
75+
}
76+
77+
record.count++
78+
return true
79+
}
80+
81+
private getClientId(client: Socket): string {
82+
// Prefer a userId stored during auth handshake, fall back to socket ID
83+
const data = client.data as Record<string, unknown>
84+
if (data && typeof data.userId === 'string') {
85+
return data.userId
86+
}
87+
return client.id || 'unknown'
88+
}
89+
90+
private cleanup(): void {
91+
const now = Date.now()
92+
let cleaned = 0
93+
for (const [key, record] of this.hits) {
94+
if (now > record.resetTime) {
95+
this.hits.delete(key)
96+
cleaned++
97+
}
98+
}
99+
if (cleaned > 0) {
100+
this.logger.debug(`WS rate limit cleanup: removed ${cleaned} expired entries`)
101+
}
102+
}
103+
}

backend/src/in-app-notifications/dto/create-notification.dto.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
import {
2-
IsString,
3-
IsEnum,
4-
IsOptional,
5-
IsNumber,
6-
IsNotEmpty,
7-
} from 'class-validator';
1+
import { IsString, IsEnum, IsOptional, IsNumber, IsNotEmpty, MaxLength } from 'class-validator';
82
import { ApiProperty } from '@nestjs/swagger';
93
import { InAppNotificationType } from '../entities/in-app-notification.entity';
104

115
export class CreateNotificationDto {
12-
@ApiProperty({ description: 'Title of the notification' })
6+
@ApiProperty({ description: 'Title of the notification', maxLength: 200 })
137
@IsString()
148
@IsNotEmpty()
9+
@MaxLength(200, { message: 'Notification title must be 200 characters or fewer' })
1510
title: string;
1611

17-
@ApiProperty({ description: 'Message content of the notification' })
12+
@ApiProperty({ description: 'Message content of the notification', maxLength: 2000 })
1813
@IsString()
1914
@IsNotEmpty()
15+
@MaxLength(2000, { message: 'Notification message must be 2000 characters or fewer' })
2016
message: string;
2117

2218
@ApiProperty({

backend/src/in-app-notifications/dto/mark-read.dto.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsArray, IsNumber } from 'class-validator';
1+
import { IsArray, IsNumber, ArrayMaxSize } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class MarkReadDto {
@@ -8,5 +8,6 @@ export class MarkReadDto {
88
})
99
@IsArray()
1010
@IsNumber({}, { each: true })
11+
@ArrayMaxSize(100, { message: 'Cannot mark more than 100 notifications at once' })
1112
notificationIds: number[];
1213
}

backend/src/in-app-notifications/dto/system-notification.dto.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
import { IsString, IsEnum, IsNotEmpty } from 'class-validator';
1+
import { IsString, IsEnum, IsNotEmpty, MaxLength } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33
import { InAppNotificationType } from '../entities/in-app-notification.entity';
44

55
export class SystemNotificationDto {
6-
@ApiProperty({ description: 'Title of the system notification' })
6+
@ApiProperty({ description: 'Title of the system notification', maxLength: 200 })
77
@IsString()
88
@IsNotEmpty()
9+
@MaxLength(200, { message: 'Notification title must be 200 characters or fewer' })
910
title: string;
1011

11-
@ApiProperty({ description: 'Message content of the system notification' })
12+
@ApiProperty({ description: 'Message content of the system notification', maxLength: 2000 })
1213
@IsString()
1314
@IsNotEmpty()
15+
@MaxLength(2000, { message: 'Notification message must be 2000 characters or fewer' })
1416
message: string;
1517

1618
@ApiProperty({

0 commit comments

Comments
 (0)