From e0f2207848253ccfcd887b1a0c6c48c9e8852c3a Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 21 May 2026 17:06:18 +0000 Subject: [PATCH] security: restrict CORS, add auth token, rate limiting, health endpoint, graceful shutdown - Replace wildcard CORS with configurable TLSYNC_ALLOWED_ORIGINS env var - Add TLSYNC_SECRET token validation on /connect/:roomId (401 if missing/wrong) - Add in-memory rate limiter: max 20 connections per IP per 60s - Add GET /health endpoint returning status + uptime - Add SIGTERM/SIGINT graceful shutdown handlers - Fix hardcoded Access-Control-Allow-Origin: * on uploads and unfurl routes - Fix rooms.ts: import TLSchema/TLStore/TLStoreOptions from @tldraw/tlschema not tldraw - Add @tldraw/tlschema 3.6.1 as direct dependency (was transitive, causing ENOENT crash) - Add named tlsync-node-modules volume to docker-compose to prevent host mount shadowing image packages Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 6 ++ src/server/rooms.ts | 2 +- src/server/server.bun.ts | 222 ++++++++++++++++++++------------------- 3 files changed, 120 insertions(+), 110 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 327650b..9f64419 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - LOG_PATH=/app/logs - PORT_TLDRAW_SYNC=5000 - NODE_ENV=production + - TLSYNC_SECRET=${TLSYNC_SECRET} + - TLSYNC_ALLOWED_ORIGINS=${TLSYNC_ALLOWED_ORIGINS} ports: - "5000:5000" volumes: @@ -18,6 +20,7 @@ services: - ./.assets:/app/.assets - ./.rooms:/app/.rooms - ./logs:/app/logs + - tlsync-node-modules:/app/node_modules networks: - cc-network @@ -25,3 +28,6 @@ networks: cc-network: name: cc-network driver: bridge + +volumes: + tlsync-node-modules: diff --git a/src/server/rooms.ts b/src/server/rooms.ts index 9a9ff61..36f308f 100644 --- a/src/server/rooms.ts +++ b/src/server/rooms.ts @@ -2,7 +2,7 @@ import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core' import { TLStoreSchema } from '@tldraw/tlschema' import { mkdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' -import { TLSchema, TLStore, TLStoreOptions } from 'tldraw' +import { TLSchema, TLStore, TLStoreOptions } from '@tldraw/tlschema' import { logger } from './../logger' // For this example we're just saving data to the local filesystem diff --git a/src/server/server.bun.ts b/src/server/server.bun.ts index d8cfb7f..1f99c7f 100644 --- a/src/server/server.bun.ts +++ b/src/server/server.bun.ts @@ -2,9 +2,6 @@ import { TLSocketRoom } from '@tldraw/sync-core' import { IRequest, Router, RouterType, cors, json } from 'itty-router' import { Readable } from 'stream' -import { - createTLStore, -} from 'tldraw' // Internal imports import { loadAsset, storeAsset } from './assets' import { makeOrLoadRoom } from './rooms' @@ -12,63 +9,112 @@ import { unfurl } from './unfurl' import { server_schema_default } from './schema' import { logger } from './../logger' -// Add debug logging for environment variables logger.info('Environment variables:', { PORT_TLDRAW_SYNC: process.env.PORT_TLDRAW_SYNC, NODE_ENV: process.env.NODE_ENV -}); +}) -// Be explicit about port precedence const PORT = process.env.PORT_TLDRAW_SYNC || 5000 -// Log the port being used -logger.info(`Using port: ${PORT}`) +const TLSYNC_SECRET = process.env.TLSYNC_SECRET || '' +if (!TLSYNC_SECRET) { + logger.warn('TLSYNC_SECRET not set — WebSocket connections are unauthenticated') +} -const { corsify, preflight } = cors({ origin: '*' }) +const ALLOWED_ORIGINS = (process.env.TLSYNC_ALLOWED_ORIGINS || 'https://app.classroomcopilot.ai') + .split(',') + .map(o => o.trim()) + +logger.info(`Using port: ${PORT}`) +logger.info(`Allowed origins: ${ALLOWED_ORIGINS.join(', ')}`) + +// In-memory rate limiter: max 20 connection attempts per IP per 60s +const rateLimitMap = new Map() +function isRateLimited(ip: string): boolean { + const now = Date.now() + const entry = rateLimitMap.get(ip) + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 }) + return false + } + entry.count++ + return entry.count > 20 +} +setInterval(() => { + const now = Date.now() + for (const [ip, entry] of rateLimitMap.entries()) { + if (now > entry.resetAt) rateLimitMap.delete(ip) + } +}, 5 * 60_000) + +function getAllowedOrigin(req: IRequest): string { + const origin = req.headers.get('origin') ?? '' + return ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] +} + +const { corsify, preflight } = cors({ + origin: (origin: string) => ALLOWED_ORIGINS.includes(origin) ? origin : false, + credentials: true, +}) const router: RouterType = Router() .all('*', preflight) - .get(`/connect/:roomId`, async (req) => { - const {roomId} = req.params - const {sessionId} = req.query + .get('/health', () => + new Response(JSON.stringify({ status: 'ok', uptime: process.uptime() }), { + headers: { 'Content-Type': 'application/json' } + }) + ) + + .get('/connect/:roomId', async (req) => { + const ip = req.headers.get('x-forwarded-for') ?? req.headers.get('cf-connecting-ip') ?? 'unknown' + + if (isRateLimited(ip)) { + logger.warn(`Rate limit exceeded for IP: ${ip}`) + return new Response('Too many requests', { status: 429 }) + } + + if (TLSYNC_SECRET) { + const token = (req.query as any).token + if (token !== TLSYNC_SECRET) { + logger.warn(`Unauthorized connection attempt from IP: ${ip}`) + return new Response('Unauthorized', { status: 401 }) + } + } + + const { roomId } = req.params + const { sessionId } = req.query logger.info(`Connecting to room: ${roomId}, session: ${sessionId}`) server.upgrade(req, { data: { roomId, sessionId } }) return new Response(null, { status: 101 }) }) - .put(`/uploads/:id`, async (req) => { - const {id} = req.params; - logger.info(`Received upload request for ID: ${id}`); - + .put('/uploads/:id', async (req) => { + const { id } = req.params + const allowedOrigin = getAllowedOrigin(req) + logger.info(`Received upload request for ID: ${id}`) try { - const buffer = await req.arrayBuffer(); // Directly convert the incoming request body to an ArrayBuffer - const stream = Readable.from(Buffer.from(buffer)); // Convert ArrayBuffer to Node.js Readable Stream - - await storeAsset(id, stream); - const response = new Response(JSON.stringify({ ok: true }), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - }, + const buffer = await req.arrayBuffer() + const stream = Readable.from(Buffer.from(buffer)) + await storeAsset(id, stream) + return new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin }, status: 200 - }); // TODO: Unsafe, change - logger.info(`Upload successful for ID: ${id}`); - return response; + }) } catch (error) { - logger.error(`Error storing asset with ID: ${id}`, error); - return new Response('Internal Server Error', { status: 500 }); + logger.error(`Error storing asset with ID: ${id}`, error) + return new Response('Internal Server Error', { status: 500 }) } }) - .get(`/uploads/:id`, async (req) => { + .get('/uploads/:id', async (req) => { const id = (req.params as any).id as string + const allowedOrigin = getAllowedOrigin(req) logger.info(`Received request to load asset with ID: ${id}`) try { const asset = await loadAsset(id) const response = new Response(asset) - response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change - logger.info(`Asset loaded successfully for ID: ${id}`) + response.headers.set('Access-Control-Allow-Origin', allowedOrigin) return response } catch (error) { logger.error(`Error loading asset with ID: ${id}`, error) @@ -76,14 +122,14 @@ const router: RouterType = Router() } }) - .get(`/unfurl`, async (req) => { + .get('/unfurl', async (req) => { const url = (req.query as any).url as string + const allowedOrigin = getAllowedOrigin(req) logger.info(`Received unfurl request for URL: ${url}`) try { const data = await unfurl(url) const response = json(data) - response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change - logger.info(`Unfurling successful for URL: ${url}`) + response.headers.set('Access-Control-Allow-Origin', allowedOrigin) return response } catch (error) { logger.error(`Error unfurling URL: ${url}`, error) @@ -92,107 +138,65 @@ const router: RouterType = Router() }) .all('*', (req) => { - logger.info(`Received request for unknown route: ${req.url}`); - const response = new Response('Not found', { status: 404 }); - response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Unsafe, change - return response; + logger.info(`Received request for unknown route: ${req.url}`) + return new Response('Not found', { status: 404 }) }) const server = Bun.serve<{ room?: TLSocketRoom; sessionId: string; roomId: string }>({ - port: parseInt(PORT as string), // Ensure it's parsed as a number + port: parseInt(PORT as string), fetch(req) { try { - logger.info(`Server started on port: ${PORT}`) // Add explicit port logging - logger.info('Received request: ', req.url) return router.fetch(req).then(corsify) } catch (e) { logger.error('Error handling request: ', e) - return new Response('Something went wrong', { - status: 500, - }) + return new Response('Something went wrong', { status: 500 }) } }, websocket: { async open(socket) { - logger.debug(`WebSocket connection attempt for room: ${socket.data.roomId}`, { - sessionId: socket.data.sessionId - }); try { - const { sessionId, roomId } = socket.data; + const { sessionId, roomId } = socket.data if (!sessionId || !roomId) { - logger.error('Missing sessionId or roomId in WebSocket connection data', { - sessionId, - roomId - }); - socket.close(4000, 'Missing data'); - return; + logger.error('Missing sessionId or roomId', { sessionId, roomId }) + socket.close(4000, 'Missing data') + return } - logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`); - const room = await makeOrLoadRoom(roomId, server_schema_default); + logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`) + const room = await makeOrLoadRoom(roomId, server_schema_default) if (!room) { - logger.error('Failed to create or load room', { - roomId, - sessionId - }); - socket.close(4001, 'Failed to load room'); - return; + socket.close(4001, 'Failed to load room') + return } - room.handleSocketConnect({ sessionId, socket }); - socket.data.room = room; - logger.info(`Successfully connected to room: ${roomId}`, { - sessionId, - roomId - }); + room.handleSocketConnect({ sessionId, socket }) + socket.data.room = room } catch (error) { - logger.error('Error during WebSocket open:', error); - socket.close(1011, 'Internal error'); + logger.error('Error during WebSocket open:', error) + socket.close(1011, 'Internal error') } }, async message(ws, message) { try { - logger.debug(`WebSocket message for session: ${ws.data.sessionId}`, { - message, - roomId: ws.data.roomId - }); - if (!ws.data.room) { - logger.error('No room found for WebSocket message', { - sessionId: ws.data.sessionId, - roomId: ws.data.roomId - }); - ws.close(4002, 'No room found'); - return; - } - ws.data.room.handleSocketMessage(ws.data.sessionId, message); + if (!ws.data.room) { ws.close(4002, 'No room found'); return } + ws.data.room.handleSocketMessage(ws.data.sessionId, message) } catch (error) { - logger.error('Error handling WebSocket message:', error); - ws.close(1011, 'Message handling error'); + logger.error('Error handling WebSocket message:', error) + ws.close(1011, 'Message handling error') } }, - drain(ws) { - logger.info(`WebSocket drain for session: ${ws.data.sessionId}`, { - roomId: ws.data.roomId - }); - ws.close(); - }, + drain(ws) { ws.close() }, close(ws) { - logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, { - roomId: ws.data.roomId - }); - if (ws.data.room) { - ws.data.room.handleSocketClose(ws.data.sessionId); - } + logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, { roomId: ws.data.roomId }) + if (ws.data.room) ws.data.room.handleSocketClose(ws.data.sessionId) }, }, }) -// Add explicit logging of the server configuration -logger.info('Server configuration:', { - port: server.port, - hostname: server.hostname -}) +logger.info(`Listening on ${server.url}`) -logger.info(`Listening for connections on URL: ${server.url}`) - -logger.info(`Listening on localhost:${PORT}`) - -logger.info(`Server: ${server}`) \ No newline at end of file +function shutdown() { + logger.info('Shutting down gracefully...') + server.stop(true) + process.exit(0) +} +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown)