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
|
- 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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user