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:
parent
68bafbebef
commit
e0f2207848
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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()
|
||||
.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<IRequest, any, any> = 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<IRequest, any, any> = 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<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) {
|
||||
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}`)
|
||||
function shutdown() {
|
||||
logger.info('Shutting down gracefully...')
|
||||
server.stop(true)
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user