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
46 changes: 28 additions & 18 deletions core/server/live/ComponentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
}
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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`
}
}
}
Expand All @@ -454,10 +449,25 @@ export class ComponentRegistry {
}

// Extract validated state
const clientState = await stateSignature.extractData(signedState)
const clientState = await stateSignature.extractData(signedState) as Record<string, any>

// 🔒 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
Expand Down Expand Up @@ -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
)

Expand Down
54 changes: 42 additions & 12 deletions core/server/live/FileUploadManager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,9 +12,24 @@ import type {

export class FileUploadManager {
private activeUploads = new Map<string, ActiveUpload>()
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<string> = 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
Expand All @@ -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`)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion core/server/live/LiveRoomManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ class LiveRoomManager {
* Componente entra em uma sala
*/
joinRoom<TState = any>(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, {
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand Down
95 changes: 91 additions & 4 deletions core/server/live/websocket-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConnectionRateLimiter>()

export const liveComponentsPlugin: Plugin = {
name: 'live-components',
version: '1.0.0',
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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!,
Expand All @@ -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 ?? {},
Expand Down
Loading
Loading