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 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-21 17:06:18 +00:00
parent 68bafbebef
commit e0f2207848
3 changed files with 120 additions and 110 deletions

View File

@ -10,6 +10,8 @@ services:
- LOG_PATH=/app/logs - LOG_PATH=/app/logs
- PORT_TLDRAW_SYNC=5000 - PORT_TLDRAW_SYNC=5000
- NODE_ENV=production - NODE_ENV=production
- TLSYNC_SECRET=${TLSYNC_SECRET}
- TLSYNC_ALLOWED_ORIGINS=${TLSYNC_ALLOWED_ORIGINS}
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
@ -18,6 +20,7 @@ services:
- ./.assets:/app/.assets - ./.assets:/app/.assets
- ./.rooms:/app/.rooms - ./.rooms:/app/.rooms
- ./logs:/app/logs - ./logs:/app/logs
- tlsync-node-modules:/app/node_modules
networks: networks:
- cc-network - cc-network
@ -25,3 +28,6 @@ networks:
cc-network: cc-network:
name: cc-network name: cc-network
driver: bridge driver: bridge
volumes:
tlsync-node-modules:

View File

@ -2,7 +2,7 @@ import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core'
import { TLStoreSchema } from '@tldraw/tlschema' import { TLStoreSchema } from '@tldraw/tlschema'
import { mkdir, readFile, writeFile } from 'fs/promises' import { mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { TLSchema, TLStore, TLStoreOptions } from 'tldraw' import { TLSchema, TLStore, TLStoreOptions } from '@tldraw/tlschema'
import { logger } from './../logger' import { logger } from './../logger'
// For this example we're just saving data to the local filesystem // For this example we're just saving data to the local filesystem

View File

@ -2,9 +2,6 @@
import { TLSocketRoom } from '@tldraw/sync-core' import { TLSocketRoom } from '@tldraw/sync-core'
import { IRequest, Router, RouterType, cors, json } from 'itty-router' import { IRequest, Router, RouterType, cors, json } from 'itty-router'
import { Readable } from 'stream' import { Readable } from 'stream'
import {
createTLStore,
} from 'tldraw'
// Internal imports // Internal imports
import { loadAsset, storeAsset } from './assets' import { loadAsset, storeAsset } from './assets'
import { makeOrLoadRoom } from './rooms' import { makeOrLoadRoom } from './rooms'
@ -12,63 +9,112 @@ import { unfurl } from './unfurl'
import { server_schema_default } from './schema' import { server_schema_default } from './schema'
import { logger } from './../logger' import { logger } from './../logger'
// Add debug logging for environment variables
logger.info('Environment variables:', { logger.info('Environment variables:', {
PORT_TLDRAW_SYNC: process.env.PORT_TLDRAW_SYNC, PORT_TLDRAW_SYNC: process.env.PORT_TLDRAW_SYNC,
NODE_ENV: process.env.NODE_ENV NODE_ENV: process.env.NODE_ENV
}); })
// Be explicit about port precedence
const PORT = process.env.PORT_TLDRAW_SYNC || 5000 const PORT = process.env.PORT_TLDRAW_SYNC || 5000
// Log the port being used const TLSYNC_SECRET = process.env.TLSYNC_SECRET || ''
logger.info(`Using port: ${PORT}`) 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<string, { count: number; resetAt: number }>()
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<IRequest, any, any> = Router() const router: RouterType<IRequest, any, any> = Router()
.all('*', preflight) .all('*', preflight)
.get(`/connect/:roomId`, async (req) => { .get('/health', () =>
const {roomId} = req.params new Response(JSON.stringify({ status: 'ok', uptime: process.uptime() }), {
const {sessionId} = req.query 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}`) logger.info(`Connecting to room: ${roomId}, session: ${sessionId}`)
server.upgrade(req, { data: { roomId, sessionId } }) server.upgrade(req, { data: { roomId, sessionId } })
return new Response(null, { status: 101 }) return new Response(null, { status: 101 })
}) })
.put(`/uploads/:id`, async (req) => { .put('/uploads/:id', async (req) => {
const {id} = req.params; const { id } = req.params
logger.info(`Received upload request for ID: ${id}`); const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received upload request for ID: ${id}`)
try { try {
const buffer = await req.arrayBuffer(); // Directly convert the incoming request body to an ArrayBuffer const buffer = await req.arrayBuffer()
const stream = Readable.from(Buffer.from(buffer)); // Convert ArrayBuffer to Node.js Readable Stream const stream = Readable.from(Buffer.from(buffer))
await storeAsset(id, stream)
await storeAsset(id, stream); return new Response(JSON.stringify({ ok: true }), {
const response = new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin },
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
status: 200 status: 200
}); // TODO: Unsafe, change })
logger.info(`Upload successful for ID: ${id}`);
return response;
} catch (error) { } catch (error) {
logger.error(`Error storing asset with ID: ${id}`, error); logger.error(`Error storing asset with ID: ${id}`, error)
return new Response('Internal Server Error', { status: 500 }); 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 id = (req.params as any).id as string
const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received request to load asset with ID: ${id}`) logger.info(`Received request to load asset with ID: ${id}`)
try { try {
const asset = await loadAsset(id) const asset = await loadAsset(id)
const response = new Response(asset) const response = new Response(asset)
response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
logger.info(`Asset loaded successfully for ID: ${id}`)
return response return response
} catch (error) { } catch (error) {
logger.error(`Error loading asset with ID: ${id}`, error) logger.error(`Error loading asset with ID: ${id}`, error)
@ -76,14 +122,14 @@ const router: RouterType<IRequest, any, any> = Router()
} }
}) })
.get(`/unfurl`, async (req) => { .get('/unfurl', async (req) => {
const url = (req.query as any).url as string const url = (req.query as any).url as string
const allowedOrigin = getAllowedOrigin(req)
logger.info(`Received unfurl request for URL: ${url}`) logger.info(`Received unfurl request for URL: ${url}`)
try { try {
const data = await unfurl(url) const data = await unfurl(url)
const response = json(data) const response = json(data)
response.headers.set('Access-Control-Allow-Origin', '*') // TODO: Unsafe, change response.headers.set('Access-Control-Allow-Origin', allowedOrigin)
logger.info(`Unfurling successful for URL: ${url}`)
return response return response
} catch (error) { } catch (error) {
logger.error(`Error unfurling URL: ${url}`, error) logger.error(`Error unfurling URL: ${url}`, error)
@ -92,107 +138,65 @@ const router: RouterType<IRequest, any, any> = Router()
}) })
.all('*', (req) => { .all('*', (req) => {
logger.info(`Received request for unknown route: ${req.url}`); logger.info(`Received request for unknown route: ${req.url}`)
const response = new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 })
response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Unsafe, change
return response;
}) })
const server = Bun.serve<{ room?: TLSocketRoom<any, void>; sessionId: string; roomId: string }>({ const server = Bun.serve<{ room?: TLSocketRoom<any, void>; sessionId: string; roomId: string }>({
port: parseInt(PORT as string), // Ensure it's parsed as a number port: parseInt(PORT as string),
fetch(req) { fetch(req) {
try { try {
logger.info(`Server started on port: ${PORT}`) // Add explicit port logging
logger.info('Received request: ', req.url)
return router.fetch(req).then(corsify) return router.fetch(req).then(corsify)
} catch (e) { } catch (e) {
logger.error('Error handling request: ', e) logger.error('Error handling request: ', e)
return new Response('Something went wrong', { return new Response('Something went wrong', { status: 500 })
status: 500,
})
} }
}, },
websocket: { websocket: {
async open(socket) { async open(socket) {
logger.debug(`WebSocket connection attempt for room: ${socket.data.roomId}`, {
sessionId: socket.data.sessionId
});
try { try {
const { sessionId, roomId } = socket.data; const { sessionId, roomId } = socket.data
if (!sessionId || !roomId) { if (!sessionId || !roomId) {
logger.error('Missing sessionId or roomId in WebSocket connection data', { logger.error('Missing sessionId or roomId', { sessionId, roomId })
sessionId, socket.close(4000, 'Missing data')
roomId return
});
socket.close(4000, 'Missing data');
return;
} }
logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`); logger.info(`WebSocket opened for room: ${roomId}, session: ${sessionId}`)
const room = await makeOrLoadRoom(roomId, server_schema_default); const room = await makeOrLoadRoom(roomId, server_schema_default)
if (!room) { if (!room) {
logger.error('Failed to create or load room', { socket.close(4001, 'Failed to load room')
roomId, return
sessionId
});
socket.close(4001, 'Failed to load room');
return;
} }
room.handleSocketConnect({ sessionId, socket }); room.handleSocketConnect({ sessionId, socket })
socket.data.room = room; socket.data.room = room
logger.info(`Successfully connected to room: ${roomId}`, {
sessionId,
roomId
});
} catch (error) { } catch (error) {
logger.error('Error during WebSocket open:', error); logger.error('Error during WebSocket open:', error)
socket.close(1011, 'Internal error'); socket.close(1011, 'Internal error')
} }
}, },
async message(ws, message) { async message(ws, message) {
try { try {
logger.debug(`WebSocket message for session: ${ws.data.sessionId}`, { if (!ws.data.room) { ws.close(4002, 'No room found'); return }
message, ws.data.room.handleSocketMessage(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);
} catch (error) { } catch (error) {
logger.error('Error handling WebSocket message:', error); logger.error('Error handling WebSocket message:', error)
ws.close(1011, 'Message handling error'); ws.close(1011, 'Message handling error')
} }
}, },
drain(ws) { drain(ws) { ws.close() },
logger.info(`WebSocket drain for session: ${ws.data.sessionId}`, {
roomId: ws.data.roomId
});
ws.close();
},
close(ws) { close(ws) {
logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, { logger.info(`WebSocket closed for session: ${ws.data.sessionId}`, { roomId: ws.data.roomId })
roomId: ws.data.roomId if (ws.data.room) ws.data.room.handleSocketClose(ws.data.sessionId)
});
if (ws.data.room) {
ws.data.room.handleSocketClose(ws.data.sessionId);
}
}, },
}, },
}) })
// Add explicit logging of the server configuration logger.info(`Listening on ${server.url}`)
logger.info('Server configuration:', {
port: server.port,
hostname: server.hostname
})
logger.info(`Listening for connections on URL: ${server.url}`) function shutdown() {
logger.info('Shutting down gracefully...')
logger.info(`Listening on localhost:${PORT}`) server.stop(true)
process.exit(0)
logger.info(`Server: ${server}`) }
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)