From a7c85e85b2f622ce5792ad83f9f4a06a97dfaf84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 16:07:17 +0000 Subject: [PATCH] fix(security): harden Live Components against critical vulnerabilities Fixes multiple security vulnerabilities in the Live Components system: CRITICAL: - Block arbitrary method execution via executeAction (CWE-94): add blocklist of internal methods + support for publicActions whitelist on components - Prevent cross-component rehydration (CWE-434): embed and validate __componentName in signed state to detect state tampering HIGH: - Add per-connection WebSocket rate limiting (CWE-770): token bucket algorithm prevents message flooding/DoS - Restrict file uploads (CWE-434): MIME type allowlist, blocked extensions, 50MB max size (was 500MB), UUID-based filenames, path traversal prevention - Add room authorization checks (CWE-269): enforce liveAuthManager.authorizeRoom on ROOM_JOIN, prevent client from setting initial room state MEDIUM: - Use crypto.randomUUID() for component and connection IDs (CWE-287) - Validate room names with regex (CWE-20): block path traversal and injection - Add room state size limits of 10MB (CWE-770) - Log auth failures instead of silently ignoring (CWE-391) - Remove stack traces and available component names from error messages (CWE-209) Includes 65 new security attack tests validating all fixes. https://claude.ai/code/session_01BAtMnYwE3T7k6F569Y4JS4 --- core/server/live/ComponentRegistry.ts | 46 +- core/server/live/FileUploadManager.ts | 54 +- core/server/live/LiveRoomManager.ts | 18 +- core/server/live/websocket-plugin.ts | 95 ++- core/types/types.ts | 61 +- .../unit/core/live-component-security.test.ts | 804 ++++++++++++++++++ 6 files changed, 1035 insertions(+), 43 deletions(-) create mode 100644 tests/unit/core/live-component-security.test.ts diff --git a/core/server/live/ComponentRegistry.ts b/core/server/live/ComponentRegistry.ts index 0a9dd1db..c3eb68ac 100644 --- a/core/server/live/ComponentRegistry.ts +++ b/core/server/live/ComponentRegistry.ts @@ -258,13 +258,9 @@ export class ComponentRegistry { } if (!ComponentClass) { - const availableComponents = [ - ...Array.from(this.definitions.keys()), - ...Array.from(this.autoDiscoveredComponents.keys()) - ] - throw new Error(`Component '${componentName}' not found. Available: ${availableComponents.join(', ')}`) + throw new Error(`Component '${componentName}' not found`) } - + // Create a default initial state for auto-discovered components initialState = {} } @@ -356,8 +352,11 @@ export class ComponentRegistry { liveLog('lifecycle', component.id, `πŸš€ Mounted component: ${componentName} (${component.id}) in ${renderTime}ms`) - // Send initial state to client with signature - const signedState = await stateSignature.signState(component.id, component.getSerializableState(), 1, { + // Send initial state to client with signature (include component name for rehydration validation) + const signedState = await stateSignature.signState(component.id, { + ...component.getSerializableState(), + __componentName: componentName + }, 1, { compress: true, backup: true }) @@ -434,13 +433,9 @@ export class ComponentRegistry { } if (!ComponentClass) { - const availableComponents = [ - ...Array.from(this.definitions.keys()), - ...Array.from(this.autoDiscoveredComponents.keys()) - ] return { success: false, - error: `Component '${componentName}' not found. Available: ${availableComponents.join(', ')}` + error: `Component '${componentName}' not found` } } } @@ -454,10 +449,25 @@ export class ComponentRegistry { } // Extract validated state - const clientState = await stateSignature.extractData(signedState) + const clientState = await stateSignature.extractData(signedState) as Record + + // πŸ”’ Security: Validate component name matches the signed state to prevent cross-component rehydration + if (clientState.__componentName && clientState.__componentName !== componentName) { + liveLog('lifecycle', componentId, '❌ Component name mismatch in rehydration - possible tampering:', { + expected: clientState.__componentName, + received: componentName + }) + return { + success: false, + error: 'Component class mismatch - state tampering detected' + } + } + + // Remove internal metadata before passing to component + const { __componentName, ...cleanState } = clientState // Create new component instance with client state (merge with initial state if from definition) - const finalState = definition ? { ...initialState, ...clientState } : clientState + const finalState = definition ? { ...initialState, ...cleanState } : cleanState const component = new ComponentClass(finalState, ws, options) // πŸ”’ Inject auth context @@ -500,10 +510,10 @@ export class ComponentRegistry { stateVersion: signedState.version }) - // Send updated state to client (with new signature) + // Send updated state to client (with new signature, include component name) const newSignedState = await stateSignature.signState( - component.id, - component.getSerializableState(), + component.id, + { ...component.getSerializableState(), __componentName: componentName }, signedState.version + 1 ) diff --git a/core/server/live/FileUploadManager.ts b/core/server/live/FileUploadManager.ts index 4146f379..055f95ba 100644 --- a/core/server/live/FileUploadManager.ts +++ b/core/server/live/FileUploadManager.ts @@ -1,9 +1,9 @@ import { writeFile, mkdir, unlink } from 'fs/promises' import { existsSync } from 'fs' -import { join, extname } from 'path' -import type { - ActiveUpload, - FileUploadStartMessage, +import { join, extname, basename } from 'path' +import type { + ActiveUpload, + FileUploadStartMessage, FileUploadChunkMessage, FileUploadCompleteMessage, FileUploadProgressResponse, @@ -12,9 +12,24 @@ import type { export class FileUploadManager { private activeUploads = new Map() - private readonly maxUploadSize = 500 * 1024 * 1024 // 500MB max (aceita qualquer arquivo) + private readonly maxUploadSize = 50 * 1024 * 1024 // πŸ”’ 50MB max (reduced from 500MB) private readonly chunkTimeout = 30000 // 30 seconds timeout per chunk - private readonly allowedTypes: string[] = [] // Array vazio = aceita todos os tipos de arquivo + // πŸ”’ Default allowed MIME types - safe file types only + private readonly allowedTypes: string[] = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', + 'application/pdf', + 'text/plain', 'text/csv', 'text/markdown', + 'application/json', + 'application/zip', 'application/gzip', + ] + // πŸ”’ Blocked file extensions that could be dangerous + private readonly blockedExtensions: Set = new Set([ + '.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif', + '.sh', '.bash', '.zsh', '.csh', + '.ps1', '.psm1', '.psd1', + '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh', + '.dll', '.sys', '.drv', '.so', '.dylib', + ]) constructor() { // Cleanup stale uploads every 5 minutes @@ -25,11 +40,28 @@ export class FileUploadManager { try { const { uploadId, componentId, filename, fileType, fileSize, chunkSize = 64 * 1024 } = message - // Validate file size (sem restriΓ§Γ£o de tipo) + // πŸ”’ Validate file size if (fileSize > this.maxUploadSize) { throw new Error(`File too large: ${fileSize} bytes. Max: ${this.maxUploadSize} bytes`) } + // πŸ”’ Validate MIME type against allowlist + if (this.allowedTypes.length > 0 && !this.allowedTypes.includes(fileType)) { + throw new Error(`File type not allowed: ${fileType}`) + } + + // πŸ”’ Validate filename - sanitize and check extension + const safeBase = basename(filename) // Strip any path traversal + const ext = extname(safeBase).toLowerCase() + if (this.blockedExtensions.has(ext)) { + throw new Error(`File extension not allowed: ${ext}`) + } + + // πŸ”’ Validate filename length + if (safeBase.length > 255) { + throw new Error('Filename too long') + } + // Check if upload already exists if (this.activeUploads.has(uploadId)) { throw new Error(`Upload ${uploadId} already in progress`) @@ -209,11 +241,9 @@ export class FileUploadManager { await mkdir(uploadsDir, { recursive: true }) } - // Generate unique filename - const timestamp = Date.now() - const extension = extname(upload.filename) - const baseName = upload.filename.replace(extension, '') - const safeFilename = `${baseName}_${timestamp}${extension}` + // πŸ”’ Generate secure unique filename using UUID (prevents path traversal and name collisions) + const extension = extname(basename(upload.filename)).toLowerCase() + const safeFilename = `${crypto.randomUUID()}${extension}` const filePath = join(uploadsDir, safeFilename) // Assemble chunks in order diff --git a/core/server/live/LiveRoomManager.ts b/core/server/live/LiveRoomManager.ts index 742831ca..25549199 100644 --- a/core/server/live/LiveRoomManager.ts +++ b/core/server/live/LiveRoomManager.ts @@ -36,6 +36,11 @@ class LiveRoomManager { * Componente entra em uma sala */ joinRoom(componentId: string, roomId: string, ws: FluxStackWebSocket, initialState?: TState): { state: TState } { + // πŸ”’ Validate room name format + if (!roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(roomId)) { + throw new Error('Invalid room name. Must be 1-64 alphanumeric characters, hyphens, underscores, dots, or colons.') + } + // Criar sala se nΓ£o existir if (!this.rooms.has(roomId)) { this.rooms.set(roomId, { @@ -164,6 +169,9 @@ class LiveRoomManager { }, excludeComponentId) } + // πŸ”’ Maximum room state size (10MB) to prevent memory exhaustion attacks + private readonly MAX_ROOM_STATE_SIZE = 10 * 1024 * 1024 + /** * Atualizar estado da sala */ @@ -172,7 +180,15 @@ class LiveRoomManager { if (!room) return // Merge estado - room.state = { ...room.state, ...updates } + const newState = { ...room.state, ...updates } + + // πŸ”’ Validate state size to prevent memory exhaustion + const stateSize = Buffer.byteLength(JSON.stringify(newState), 'utf8') + if (stateSize > this.MAX_ROOM_STATE_SIZE) { + throw new Error('Room state exceeds maximum size limit') + } + + room.state = newState room.lastActivity = Date.now() // Notificar todos os membros diff --git a/core/server/live/websocket-plugin.ts b/core/server/live/websocket-plugin.ts index b9d9297e..055234fe 100644 --- a/core/server/live/websocket-plugin.ts +++ b/core/server/live/websocket-plugin.ts @@ -116,6 +116,39 @@ const LiveAlertResolveSchema = t.Object({ description: 'Result of alert resolution operation' }) +// πŸ”’ Per-connection rate limiter to prevent WebSocket message flooding +class ConnectionRateLimiter { + private tokens: number + private lastRefill: number + private readonly maxTokens: number + private readonly refillRate: number // tokens per second + + constructor(maxTokens = 100, refillRate = 50) { + this.maxTokens = maxTokens + this.tokens = maxTokens + this.refillRate = refillRate + this.lastRefill = Date.now() + } + + tryConsume(count = 1): boolean { + this.refill() + if (this.tokens >= count) { + this.tokens -= count + return true + } + return false + } + + private refill(): void { + const now = Date.now() + const elapsed = (now - this.lastRefill) / 1000 + this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate) + this.lastRefill = now + } +} + +const connectionRateLimiters = new Map() + export const liveComponentsPlugin: Plugin = { name: 'live-components', version: '1.0.0', @@ -143,9 +176,12 @@ export const liveComponentsPlugin: Plugin = { async open(ws) { const socket = ws as unknown as FluxStackWebSocket - const connectionId = `ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const connectionId = `ws-${crypto.randomUUID()}` liveLog('websocket', null, `πŸ”Œ Live Components WebSocket connected: ${connectionId}`) + // πŸ”’ Initialize rate limiter for this connection + connectionRateLimiters.set(connectionId, new ConnectionRateLimiter()) + // Register connection with enhanced connection manager connectionManager.registerConnection(ws as unknown as FluxStackWebSocket, connectionId, 'live-components') @@ -177,10 +213,14 @@ export const liveComponentsPlugin: Plugin = { if (authContext.authenticated) { socket.data.userId = authContext.user?.id liveLog('websocket', null, `πŸ”’ WebSocket authenticated via query: user=${authContext.user?.id}`) + } else { + // πŸ”’ Log failed auth attempts (token was provided but auth failed) + liveLog('websocket', null, `πŸ”’ WebSocket authentication failed via query token`) } } - } catch { - // Query param auth is optional - continue without auth + } catch (authError) { + // πŸ”’ Log auth errors instead of silently ignoring them + console.warn('πŸ”’ WebSocket query auth error:', authError instanceof Error ? authError.message : 'Unknown error') } // Send connection confirmation @@ -203,6 +243,20 @@ export const liveComponentsPlugin: Plugin = { async message(ws: unknown, rawMessage: LiveMessage | ArrayBuffer | Uint8Array) { const socket = ws as FluxStackWebSocket try { + // πŸ”’ Rate limiting: reject messages if connection exceeds rate limit + const connId = socket.data?.connectionId + if (connId) { + const limiter = connectionRateLimiters.get(connId) + if (limiter && !limiter.tryConsume()) { + socket.send(JSON.stringify({ + type: 'ERROR', + error: 'Rate limit exceeded. Please slow down.', + timestamp: Date.now() + })) + return + } + } + let message: LiveMessage let binaryChunkData: Buffer | null = null @@ -313,6 +367,11 @@ export const liveComponentsPlugin: Plugin = { const connectionId = socket.data?.connectionId liveLog('websocket', null, `πŸ”Œ Live Components WebSocket disconnected: ${connectionId}`) + // πŸ”’ Cleanup rate limiter + if (connectionId) { + connectionRateLimiters.delete(connectionId) + } + // Cleanup connection in connection manager if (connectionId) { connectionManager.cleanupConnection(connectionId) @@ -781,11 +840,23 @@ async function handleRoomJoin(ws: FluxStackWebSocket, message: RoomMessage) { liveLog('rooms', message.componentId, `πŸšͺ Component ${message.componentId} joining room ${message.roomId}`) try { + // πŸ”’ Validate room name format (alphanumeric, hyphens, underscores, max 64 chars) + if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) { + throw new Error('Invalid room name. Must be 1-64 alphanumeric characters, hyphens, underscores, dots, or colons.') + } + + // πŸ”’ Room authorization check + const authContext = ws.data?.authContext || ANONYMOUS_CONTEXT + const authResult = await liveAuthManager.authorizeRoom(authContext, message.roomId) + if (!authResult.allowed) { + throw new Error(`Room access denied: ${authResult.reason}`) + } + const result = liveRoomManager.joinRoom( message.componentId, message.roomId, ws, - message.data?.initialState + undefined // πŸ”’ Don't allow client to set initial room state - server controls this ) const response = { @@ -843,6 +914,11 @@ async function handleRoomEmit(ws: FluxStackWebSocket, message: RoomMessage) { liveLog('rooms', message.componentId, `πŸ“‘ Component ${message.componentId} emitting '${message.event}' to room ${message.roomId}`) try { + // πŸ”’ Validate room name + if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) { + throw new Error('Invalid room name') + } + const count = liveRoomManager.emitToRoom( message.roomId, message.event!, @@ -866,6 +942,17 @@ async function handleRoomStateSet(ws: FluxStackWebSocket, message: RoomMessage) liveLog('rooms', message.componentId, `πŸ“ Component ${message.componentId} updating state in room ${message.roomId}`) try { + // πŸ”’ Validate room name + if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) { + throw new Error('Invalid room name') + } + + // πŸ”’ Validate state size (max 1MB per update to prevent memory attacks) + const stateStr = JSON.stringify(message.data ?? {}) + if (stateStr.length > 1024 * 1024) { + throw new Error('Room state update too large (max 1MB)') + } + liveRoomManager.setRoomState( message.roomId, message.data ?? {}, diff --git a/core/types/types.ts b/core/types/types.ts index 5ca4dcf8..b7b8d4ad 100644 --- a/core/types/types.ts +++ b/core/types/types.ts @@ -523,23 +523,68 @@ export abstract class LiveComponent { return { success: true, key, value } } - // Execute action safely + /** + * List of methods that are explicitly callable from the client. + * If defined in a subclass, ONLY these methods can be called via CALL_ACTION. + * If not defined, all methods except the blocklist are callable (legacy behavior). + * + * @example + * static publicActions = ['sendMessage', 'deleteMessage', 'join'] as const + */ + static publicActions?: readonly string[] + + // Internal methods that must NEVER be callable from the client + private static readonly BLOCKED_ACTIONS: ReadonlySet = new Set([ + // Lifecycle & internal + 'constructor', 'destroy', 'executeAction', 'getSerializableState', + // State management internals + 'setState', 'emit', 'broadcast', 'broadcastToRoom', + 'createStateProxy', 'createDirectStateAccessors', 'generateId', + // Auth internals + 'setAuthContext', '$auth', + // Room internals + '$room', '$rooms', 'subscribeToRoom', 'unsubscribeFromRoom', + 'emitRoomEvent', 'onRoomEvent', 'emitRoomEventWithState', + ]) + + // Execute action safely with security validation public async executeAction(action: string, payload: any): Promise { try { - // Check if method exists + // πŸ”’ Security: Block internal/protected methods from being called remotely + if ((LiveComponent.BLOCKED_ACTIONS as Set).has(action)) { + throw new Error(`Action '${action}' is not callable`) + } + + // πŸ”’ Security: Block private methods (prefixed with _ or #) + if (action.startsWith('_') || action.startsWith('#')) { + throw new Error(`Action '${action}' is not callable`) + } + + // πŸ”’ Security: If publicActions whitelist is defined, enforce it + const componentClass = this.constructor as typeof LiveComponent + const publicActions = componentClass.publicActions + if (publicActions && !publicActions.includes(action)) { + throw new Error(`Action '${action}' is not callable`) + } + + // Check if method exists on the instance const method = (this as any)[action] if (typeof method !== 'function') { throw new Error(`Action '${action}' not found on component`) } + // πŸ”’ Security: Block inherited Object.prototype methods + if (Object.prototype.hasOwnProperty.call(Object.prototype, action)) { + throw new Error(`Action '${action}' is not callable`) + } + // Execute method const result = await method.call(this, payload) return result } catch (error: any) { - this.emit('ERROR', { - action, - error: error.message, - stack: error.stack + this.emit('ERROR', { + action, + error: error.message }) throw error } @@ -665,9 +710,9 @@ export abstract class LiveComponent { // Registry will handle the actual unsubscription } - // Generate unique ID + // Generate unique ID using cryptographically secure randomness private generateId(): string { - return `live-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + return `live-${crypto.randomUUID()}` } // Cleanup when component is destroyed diff --git a/tests/unit/core/live-component-security.test.ts b/tests/unit/core/live-component-security.test.ts new file mode 100644 index 00000000..3be0f496 --- /dev/null +++ b/tests/unit/core/live-component-security.test.ts @@ -0,0 +1,804 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * πŸ”’ Security Attack Tests for Live Components + * + * These tests simulate real-world attacks against the Live Components system + * to verify that all security fixes are working correctly. + * + * Covers: + * - CVE-like: Arbitrary method execution (CWE-94) + * - CVE-like: Rehydration component class mismatch (CWE-434) + * - CVE-like: Unrestricted file uploads (CWE-434) + * - CVE-like: Room name injection (CWE-20) + * - CVE-like: Room state size exhaustion (CWE-770) + * - CVE-like: Component ID predictability (CWE-287) + * - CVE-like: Information disclosure in errors (CWE-209) + * - CVE-like: WebSocket rate limiting (CWE-770) + */ + +// Mock the room dependencies before importing +vi.mock('@core/server/live/RoomEventBus', () => ({ + roomEvents: { + on: vi.fn(), + emit: vi.fn(), + off: vi.fn() + } +})) + +vi.mock('@core/server/live/LiveRoomManager', () => ({ + liveRoomManager: { + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + emitToRoom: vi.fn(), + getRoomState: vi.fn(() => ({})), + setRoomState: vi.fn() + } +})) + +// Import after mocks +import { LiveComponent } from '@core/types/types' +import type { FluxStackWebSocket, LiveMessage } from '@core/types/types' +import type { LiveActionAuthMap } from '@core/server/live/auth/types' + +// ===== Test Helpers ===== + +function createMockWs(): FluxStackWebSocket { + return { + send: vi.fn(), + close: vi.fn(), + data: { + connectionId: 'test-conn', + components: new Map(), + subscriptions: new Set(), + connectedAt: new Date() + }, + remoteAddress: '127.0.0.1', + readyState: 1 + } as unknown as FluxStackWebSocket +} + +function getLastSentMessage(ws: FluxStackWebSocket): any { + const sendMock = ws.send as ReturnType + const lastCall = sendMock.mock.calls[sendMock.mock.calls.length - 1] + return lastCall ? JSON.parse(lastCall[0]) : null +} + +// ===== Test Components ===== + +interface ChatState { + messages: string[] + isAdmin: boolean + secretKey: string +} + +// Component WITHOUT publicActions (legacy - uses blocklist) +class LegacyComponent extends LiveComponent { + static componentName = 'LegacyComponent' + static defaultState: ChatState = { messages: [], isAdmin: false, secretKey: 'secret-123' } + + async sendMessage(payload: { text: string }) { + this.state.messages = [...this.state.messages, payload.text] + return { success: true } + } + + async deleteMessage(payload: { index: number }) { + return { success: true } + } + + // This internal method should NOT be callable from client + private _internalProcess() { + return { internal: true } + } +} + +// Component WITH publicActions whitelist (secure pattern) +class SecureComponent extends LiveComponent { + static componentName = 'SecureComponent' + static defaultState: ChatState = { messages: [], isAdmin: false, secretKey: 'secret-456' } + static publicActions = ['sendMessage', 'deleteMessage'] as const + + async sendMessage(payload: { text: string }) { + return { success: true } + } + + async deleteMessage(payload: { index: number }) { + return { success: true } + } + + // This method is NOT in publicActions - should be blocked + async resetDatabase() { + return { dangerous: true } + } +} + +// Component with action-level auth +class AuthProtectedComponent extends LiveComponent { + static componentName = 'AuthProtectedComponent' + static defaultState: ChatState = { messages: [], isAdmin: false, secretKey: 'secret-789' } + static publicActions = ['sendMessage', 'adminAction'] as const + static actionAuth: LiveActionAuthMap = { + adminAction: { roles: ['admin'] } + } + + async sendMessage(payload: { text: string }) { + return { success: true } + } + + async adminAction() { + return { admin: true } + } +} + +// ===== ATTACK TESTS ===== + +describe('πŸ”’ Security: Arbitrary Method Execution (CWE-94)', () => { + let ws: FluxStackWebSocket + let legacy: LegacyComponent + let secure: SecureComponent + + beforeEach(() => { + ws = createMockWs() + legacy = new LegacyComponent({}, ws) + secure = new SecureComponent({}, ws) + }) + + describe('Blocked internal methods (blocklist)', () => { + it('ATTACK: should block calling destroy() remotely', async () => { + await expect(legacy.executeAction('destroy', {})).rejects.toThrow("Action 'destroy' is not callable") + }) + + it('ATTACK: should block calling emit() remotely', async () => { + await expect(legacy.executeAction('emit', { type: 'ADMIN_ALERT', payload: {} })).rejects.toThrow("Action 'emit' is not callable") + }) + + it('ATTACK: should block calling setState() remotely', async () => { + await expect(legacy.executeAction('setState', { isAdmin: true })).rejects.toThrow("Action 'setState' is not callable") + }) + + it('ATTACK: should block calling setAuthContext() remotely to escalate privileges', async () => { + await expect(legacy.executeAction('setAuthContext', { + authenticated: true, + user: { id: 'hacker', roles: ['admin'] } + })).rejects.toThrow("Action 'setAuthContext' is not callable") + }) + + it('ATTACK: should block calling broadcast() remotely', async () => { + await expect(legacy.executeAction('broadcast', { type: 'FAKE', payload: {} })).rejects.toThrow("Action 'broadcast' is not callable") + }) + + it('ATTACK: should block calling broadcastToRoom() remotely', async () => { + await expect(legacy.executeAction('broadcastToRoom', {})).rejects.toThrow("Action 'broadcastToRoom' is not callable") + }) + + it('ATTACK: should block calling executeAction() recursively', async () => { + await expect(legacy.executeAction('executeAction', { action: 'destroy', payload: {} })).rejects.toThrow("Action 'executeAction' is not callable") + }) + + it('ATTACK: should block calling getSerializableState() remotely', async () => { + await expect(legacy.executeAction('getSerializableState', {})).rejects.toThrow("Action 'getSerializableState' is not callable") + }) + + it('ATTACK: should block calling constructor remotely', async () => { + await expect(legacy.executeAction('constructor', {})).rejects.toThrow("Action 'constructor' is not callable") + }) + + it('ATTACK: should block calling subscribeToRoom() remotely', async () => { + await expect(legacy.executeAction('subscribeToRoom', 'admin-room')).rejects.toThrow("Action 'subscribeToRoom' is not callable") + }) + + it('ATTACK: should block calling emitRoomEvent() remotely', async () => { + await expect(legacy.executeAction('emitRoomEvent', { event: 'hack', data: {} })).rejects.toThrow("Action 'emitRoomEvent' is not callable") + }) + + it('ATTACK: should block calling onRoomEvent() remotely', async () => { + await expect(legacy.executeAction('onRoomEvent', {})).rejects.toThrow("Action 'onRoomEvent' is not callable") + }) + }) + + describe('Blocked private methods (underscore prefix)', () => { + it('ATTACK: should block calling _internalProcess() remotely', async () => { + await expect(legacy.executeAction('_internalProcess', {})).rejects.toThrow("Action '_internalProcess' is not callable") + }) + + it('ATTACK: should block any method starting with _', async () => { + await expect(legacy.executeAction('_anything', {})).rejects.toThrow("Action '_anything' is not callable") + }) + + it('ATTACK: should block any method starting with #', async () => { + await expect(legacy.executeAction('#privateField', {})).rejects.toThrow("Action '#privateField' is not callable") + }) + }) + + describe('Blocked Object.prototype methods', () => { + it('ATTACK: should block calling toString() remotely', async () => { + await expect(legacy.executeAction('toString', {})).rejects.toThrow("Action 'toString' is not callable") + }) + + it('ATTACK: should block calling hasOwnProperty() remotely', async () => { + await expect(legacy.executeAction('hasOwnProperty', 'state')).rejects.toThrow("Action 'hasOwnProperty' is not callable") + }) + + it('ATTACK: should block calling valueOf() remotely', async () => { + await expect(legacy.executeAction('valueOf', {})).rejects.toThrow("Action 'valueOf' is not callable") + }) + }) + + describe('publicActions whitelist enforcement', () => { + it('should allow calling whitelisted action', async () => { + const result = await secure.executeAction('sendMessage', { text: 'hello' }) + expect(result.success).toBe(true) + }) + + it('should allow calling another whitelisted action', async () => { + const result = await secure.executeAction('deleteMessage', { index: 0 }) + expect(result.success).toBe(true) + }) + + it('ATTACK: should block calling non-whitelisted method', async () => { + await expect(secure.executeAction('resetDatabase', {})).rejects.toThrow("Action 'resetDatabase' is not callable") + }) + + it('ATTACK: should block calling destroy even on secure component', async () => { + await expect(secure.executeAction('destroy', {})).rejects.toThrow("Action 'destroy' is not callable") + }) + + it('ATTACK: should block calling setAuthContext on secure component', async () => { + await expect(secure.executeAction('setAuthContext', { + authenticated: true, user: { id: 'hacker', roles: ['admin'] } + })).rejects.toThrow("Action 'setAuthContext' is not callable") + }) + }) + + describe('Legitimate actions still work', () => { + it('should allow calling public methods on legacy component', async () => { + const result = await legacy.executeAction('sendMessage', { text: 'hello' }) + expect(result.success).toBe(true) + }) + + it('should allow calling deleteMessage on legacy component', async () => { + const result = await legacy.executeAction('deleteMessage', { index: 0 }) + expect(result.success).toBe(true) + }) + + it('should return error for non-existent action', async () => { + await expect(legacy.executeAction('nonExistentMethod', {})).rejects.toThrow("Action 'nonExistentMethod' not found on component") + }) + }) + + describe('Error messages do not leak stack traces', () => { + it('should not include stack trace in error emit', async () => { + // Clear previous sends + ;(ws.send as ReturnType).mockClear() + + try { + await legacy.executeAction('nonExistentMethod', {}) + } catch { /* expected */ } + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('ERROR') + expect(msg.payload.error).toBeDefined() + // Stack trace should NOT be included + expect(msg.payload.stack).toBeUndefined() + }) + }) +}) + +describe('πŸ”’ Security: Component ID Predictability (CWE-287)', () => { + let ws: FluxStackWebSocket + + beforeEach(() => { + ws = createMockWs() + }) + + it('should generate cryptographically secure component IDs', () => { + const component = new LegacyComponent({}, ws) + // Should use UUID format: live-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + expect(component.id).toMatch(/^live-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + }) + + it('should generate unique IDs for each component', () => { + const ids = new Set() + for (let i = 0; i < 100; i++) { + const component = new LegacyComponent({}, ws) + ids.add(component.id) + } + // All 100 IDs should be unique + expect(ids.size).toBe(100) + }) + + it('should NOT use predictable timestamp-based IDs', () => { + const component = new LegacyComponent({}, ws) + // Old format was: live-- + // New format should be: live- + expect(component.id).not.toMatch(/^live-\d{13}-/) + }) +}) + +describe('πŸ”’ Security: File Upload Restrictions (CWE-434)', () => { + // We test the FileUploadManager directly + let fileUploadManager: any + + beforeEach(async () => { + // Dynamic import to get the actual instance + const mod = await import('@core/server/live/FileUploadManager') + fileUploadManager = new mod.FileUploadManager() + }) + + describe('MIME type validation', () => { + it('should allow safe image uploads', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-1', + componentId: 'comp-1', + filename: 'photo.jpg', + fileType: 'image/jpeg', + fileSize: 1024 * 100, // 100KB + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(true) + }) + + it('should allow PDF uploads', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-2', + componentId: 'comp-1', + filename: 'document.pdf', + fileType: 'application/pdf', + fileSize: 1024 * 500, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(true) + }) + + it('ATTACK: should block executable uploads', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-3', + componentId: 'comp-1', + filename: 'malware.exe', + fileType: 'application/x-msdownload', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('File type not allowed') + }) + + it('ATTACK: should block octet-stream uploads', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-4', + componentId: 'comp-1', + filename: 'payload.bin', + fileType: 'application/octet-stream', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('File type not allowed') + }) + }) + + describe('File extension validation', () => { + it('ATTACK: should block .exe extension even with allowed MIME', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-5', + componentId: 'comp-1', + filename: 'image.exe', + fileType: 'image/jpeg', // Lies about MIME type + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('extension not allowed') + }) + + it('ATTACK: should block .sh shell script extension', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-6', + componentId: 'comp-1', + filename: 'setup.sh', + fileType: 'text/plain', // Valid MIME but dangerous extension + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('extension not allowed') + }) + + it('ATTACK: should block .bat batch file extension', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-7', + componentId: 'comp-1', + filename: 'run.bat', + fileType: 'text/plain', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('extension not allowed') + }) + + it('ATTACK: should block .ps1 PowerShell extension', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-8', + componentId: 'comp-1', + filename: 'exploit.ps1', + fileType: 'text/plain', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('extension not allowed') + }) + + it('ATTACK: should block .dll shared library extension', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-9', + componentId: 'comp-1', + filename: 'hack.dll', + fileType: 'application/octet-stream', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + // Could fail on MIME or extension - either is fine + expect(result.error).toBeDefined() + }) + }) + + describe('File size validation', () => { + it('ATTACK: should block files larger than 50MB', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-10', + componentId: 'comp-1', + filename: 'huge.jpg', + fileType: 'image/jpeg', + fileSize: 100 * 1024 * 1024, // 100MB + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('File too large') + }) + + it('should allow files under 50MB', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-11', + componentId: 'comp-1', + filename: 'normal.jpg', + fileType: 'image/jpeg', + fileSize: 10 * 1024 * 1024, // 10MB + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(true) + }) + }) + + describe('Filename sanitization', () => { + it('ATTACK: should strip path traversal from filename', async () => { + const result = await fileUploadManager.startUpload({ + uploadId: 'test-12', + componentId: 'comp-1', + filename: '../../../etc/passwd', + fileType: 'text/plain', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + // The path.basename should strip traversal, keeping only "passwd" + // This should succeed because text/plain is allowed and "passwd" has no blocked extension + expect(result.success).toBe(true) + }) + + it('ATTACK: should reject excessively long filenames', async () => { + const longName = 'a'.repeat(300) + '.jpg' + const result = await fileUploadManager.startUpload({ + uploadId: 'test-13', + componentId: 'comp-1', + filename: longName, + fileType: 'image/jpeg', + fileSize: 1024, + type: 'FILE_UPLOAD_START' + }) + expect(result.success).toBe(false) + expect(result.error).toContain('Filename too long') + }) + }) +}) + +describe('πŸ”’ Security: Room Name Validation (CWE-20)', () => { + // Test the room name validation regex that is enforced in LiveRoomManager and websocket-plugin + const validRoomRegex = /^[a-zA-Z0-9_:.-]{1,64}$/ + + it('ATTACK: should reject room names with path traversal', () => { + expect(validRoomRegex.test('../admin')).toBe(false) + expect(validRoomRegex.test('../../etc/passwd')).toBe(false) + expect(validRoomRegex.test('room/../../hack')).toBe(false) + }) + + it('ATTACK: should reject room names with special characters', () => { + const validRoomRegex = /^[a-zA-Z0-9_:.-]{1,64}$/ + + expect(validRoomRegex.test('room