diff --git a/BackendAcademy/src/chat/chat-rate-limit.ts b/BackendAcademy/src/chat/chat-rate-limit.ts new file mode 100644 index 000000000..effe2902f --- /dev/null +++ b/BackendAcademy/src/chat/chat-rate-limit.ts @@ -0,0 +1,59 @@ +/** + * Session-based chat rate limiting. + * + * Tracks message timestamps per session (keyed by senderId) using a sliding + * window so a single user cannot flood chat rooms. This is scoped to the chat + * module and is independent of the global ThrottlerGuard, which limits by IP. + */ +export interface ChatRateLimitConfig { + /** Maximum number of messages allowed within the window. */ + maxMessages: number; + /** Length of the sliding window in milliseconds. */ + windowMs: number; +} + +export const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimitConfig = { + maxMessages: 10, + windowMs: 10_000, +}; + +export class ChatRateLimiter { + private readonly hits = new Map(); + + constructor( + private readonly config: ChatRateLimitConfig = DEFAULT_CHAT_RATE_LIMIT, + ) {} + + /** + * Records an attempt for the given session and reports whether it is allowed. + * Returns the number of seconds to wait when the limit has been exceeded. + */ + check(sessionId: string, now: number = Date.now()): { + allowed: boolean; + retryAfterSeconds: number; + } { + const windowStart = now - this.config.windowMs; + const recent = (this.hits.get(sessionId) ?? []).filter( + (timestamp) => timestamp > windowStart, + ); + + if (recent.length >= this.config.maxMessages) { + this.hits.set(sessionId, recent); + const oldest = recent[0]; + const retryAfterMs = oldest + this.config.windowMs - now; + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), + }; + } + + recent.push(now); + this.hits.set(sessionId, recent); + return { allowed: true, retryAfterSeconds: 0 }; + } + + /** Clears all tracked sessions (primarily for testing). */ + reset(): void { + this.hits.clear(); + } +} diff --git a/BackendAcademy/src/chat/chat.service.spec.ts b/BackendAcademy/src/chat/chat.service.spec.ts index 300f30511..1b3660b15 100644 --- a/BackendAcademy/src/chat/chat.service.spec.ts +++ b/BackendAcademy/src/chat/chat.service.spec.ts @@ -1,4 +1,90 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; import { ChatService } from './chat.service'; +import { + ChatRateLimiter, + DEFAULT_CHAT_RATE_LIMIT, +} from './chat-rate-limit'; + +describe('ChatRateLimiter', () => { + it('allows messages up to the configured limit then blocks', () => { + const limiter = new ChatRateLimiter({ maxMessages: 3, windowMs: 1000 }); + const now = 1_000_000; + + expect(limiter.check('session-1', now).allowed).toBe(true); + expect(limiter.check('session-1', now).allowed).toBe(true); + expect(limiter.check('session-1', now).allowed).toBe(true); + + const blocked = limiter.check('session-1', now); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('tracks each session independently', () => { + const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 }); + const now = 1_000_000; + + expect(limiter.check('session-a', now).allowed).toBe(true); + expect(limiter.check('session-a', now).allowed).toBe(false); + // A different session is unaffected by session-a's usage. + expect(limiter.check('session-b', now).allowed).toBe(true); + }); + + it('allows messages again once the window has elapsed', () => { + const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 }); + const start = 1_000_000; + + expect(limiter.check('session-1', start).allowed).toBe(true); + expect(limiter.check('session-1', start).allowed).toBe(false); + // Past the window the slate is clean. + expect(limiter.check('session-1', start + 1001).allowed).toBe(true); + }); +}); + +describe('ChatService rate limiting', () => { + it('rejects messages once a session exceeds the limit', () => { + const service = new ChatService(); + const send = () => + service.createMessage({ + roomId: 'room-1', + senderId: 'spammer', + content: 'hi', + }); + + for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) { + expect(() => send()).not.toThrow(); + } + + expect(() => send()).toThrow(HttpException); + try { + send(); + } catch (err) { + expect((err as HttpException).getStatus()).toBe( + HttpStatus.TOO_MANY_REQUESTS, + ); + } + }); + + it('shares the limit across createMessage and shareCodeSnippet', () => { + const service = new ChatService(); + for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) { + service.createMessage({ + roomId: 'room-1', + senderId: 'user-1', + content: 'hi', + }); + } + + expect(() => + service.shareCodeSnippet({ + roomId: 'room-1', + senderId: 'user-1', + content: 'snippet', + code: 'fn main() {}', + language: 'rust', + }), + ).toThrow(HttpException); + }); +}); describe('ChatService code snippet sharing', () => { it('creates a shared code snippet message with metadata', () => { diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts index e62d346f9..c689b1fc5 100644 --- a/BackendAcademy/src/chat/chat.service.ts +++ b/BackendAcademy/src/chat/chat.service.ts @@ -1,13 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ChatRoom, Message } from './interfaces/chat.interface'; import { CreateMessageDto } from './dto/create-message.dto'; import { CreateRoomDto } from './dto/create-room.dto'; import { ShareCodeSnippetDto } from './dto/share-code-snippet.dto'; +import { ChatRateLimiter } from './chat-rate-limit'; @Injectable() export class ChatService { private rooms: ChatRoom[] = []; private messages: Message[] = []; + private readonly rateLimiter = new ChatRateLimiter(); createRoom(createRoomDto: CreateRoomDto): ChatRoom { const newRoom: ChatRoom = { @@ -28,6 +30,7 @@ export class ChatService { } createMessage(createMessageDto: CreateMessageDto): Message { + this.enforceRateLimit(createMessageDto.senderId); const newMessage: Message = { id: Math.random().toString(36).substring(2, 9), ...createMessageDto, @@ -38,6 +41,7 @@ export class ChatService { } shareCodeSnippet(shareCodeSnippetDto: ShareCodeSnippetDto): Message { + this.enforceRateLimit(shareCodeSnippetDto.senderId); const newMessage: Message = { id: Math.random().toString(36).substring(2, 9), ...shareCodeSnippetDto, @@ -56,4 +60,18 @@ export class ChatService { findMessagesByRoom(roomId: string): Message[] { return this.messages.filter((m) => m.roomId === roomId); } + + private enforceRateLimit(senderId: string): void { + const { allowed, retryAfterSeconds } = this.rateLimiter.check(senderId); + if (!allowed) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Chat rate limit exceeded. Please slow down.', + retryAfter: retryAfterSeconds, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } }