Skip to content

Commit 4012c78

Browse files
security: harden canvas server against common vulnerabilities
- Add helmet for security headers (CSP disabled for dev) - Add express-rate-limit (60 req/min on /api/ endpoints) - Add WebSocket maxPayload (64KB) and origin validation - Restrict CORS to localhost origins - Add body size limit to express.json (10kb)
1 parent f2f75da commit 4012c78

3 files changed

Lines changed: 80 additions & 10 deletions

File tree

package-lock.json

Lines changed: 21 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"d3-force": "^3.0.0",
4646
"dotenv": "^16.4.0",
4747
"express": "^4.21.0",
48+
"express-rate-limit": "^8.5.2",
49+
"helmet": "^8.2.0",
4850
"pdf-lib": "^1.17.1",
4951
"tldraw": "^4.5.0",
5052
"ws": "^8.18.0",

src/canvas-server.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*/
99

1010
import express, { Request, Response, NextFunction } from 'express'
11+
import rateLimit from 'express-rate-limit'
12+
import helmet from 'helmet'
1113
import { createServer } from 'http'
1214
import { WebSocketServer, WebSocket } from 'ws'
1315
import path from 'path'
@@ -38,7 +40,36 @@ const snapshots = new Map<string, { name: string; elements: CanvasElement[]; cre
3840

3941
const app = express()
4042
const httpServer = createServer(app)
41-
const wss = new WebSocketServer({ server: httpServer, path: '/ws' })
43+
const wss = new WebSocketServer({
44+
server: httpServer,
45+
path: '/ws',
46+
maxPayload: 64 * 1024,
47+
verifyClient: (info, callback) => {
48+
const origin = info.origin || info.req.headers.origin
49+
const host = info.req.headers.host
50+
51+
if (!origin && !host) {
52+
callback(true)
53+
return
54+
}
55+
56+
if (origin) {
57+
try {
58+
const url = new URL(origin)
59+
const allowedHosts = ['localhost', '127.0.0.1', host?.split(':')[0]]
60+
if (!allowedHosts.includes(url.hostname)) {
61+
callback(false, 403, 'Forbidden: invalid origin')
62+
return
63+
}
64+
} catch {
65+
callback(false, 403, 'Forbidden: invalid origin')
66+
return
67+
}
68+
}
69+
70+
callback(true)
71+
},
72+
})
4273

4374
/** Active WebSocket clients */
4475
const wsClients = new Set<WebSocket>()
@@ -134,11 +165,34 @@ function requestScreenshot(format: 'png' | 'svg', background: boolean): Promise<
134165

135166
// ─── Middleware ───────────────────────────────────────────────────────────────
136167

137-
app.use(express.json())
168+
app.use(helmet({
169+
contentSecurityPolicy: false,
170+
crossOriginEmbedderPolicy: false,
171+
}))
172+
173+
app.use(express.json({ limit: '10kb' }))
174+
175+
const apiLimiter = rateLimit({
176+
windowMs: 60 * 1000,
177+
limit: 60,
178+
standardHeaders: 'draft-8',
179+
legacyHeaders: false,
180+
message: { success: false, error: 'Too many requests, please try again later' },
181+
})
182+
183+
app.use('/api/', apiLimiter)
138184

139185
// CORS — allow Vite dev server on :5173 during development
140186
app.use((_req: Request, res: Response, next: NextFunction) => {
141-
res.setHeader('Access-Control-Allow-Origin', '*')
187+
const origin = _req.headers.origin
188+
const allowedOrigins = ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:3000', 'http://127.0.0.1:3000']
189+
190+
if (origin && allowedOrigins.includes(origin)) {
191+
res.setHeader('Access-Control-Allow-Origin', origin)
192+
} else if (!origin) {
193+
res.setHeader('Access-Control-Allow-Origin', '*')
194+
}
195+
142196
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
143197
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
144198
next()

0 commit comments

Comments
 (0)