From 804c2586a9d7f1fad9c293a95df1c19bfda86008 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 23:34:55 +0000 Subject: [PATCH] fix: tighten CSP, fix API key exposure, improve error handling - Remove 'unsafe-inline' from script-src CSP directive - Restrict connectSrc and imgSrc to known external domains - Move Google API key from URL query parameter to X-Goog-Api-Key header - Sanitize error logging in production (no stack traces) - Log file link errors instead of silently swallowing them https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8 --- server/src/index.ts | 31 +++++++++++++++++++++++-------- server/src/routes/files.ts | 4 +++- server/src/routes/maps.ts | 3 ++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index db4bba7..1031482 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,5 @@ import 'dotenv/config'; -import './config'; +import { JWT_SECRET_IS_GENERATED } from './config'; import express, { Request, Response, NextFunction } from 'express'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; @@ -57,13 +57,21 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], - imgSrc: ["'self'", "data:", "blob:", "https:", "http:"], - connectSrc: ["'self'", "ws:", "wss:", "https:", "http:"], + imgSrc: ["'self'", "data:", "blob:", "https:"], + connectSrc: [ + "'self'", "ws:", "wss:", + "https://nominatim.openstreetmap.org", "https://overpass-api.de", + "https://places.googleapis.com", "https://api.openweathermap.org", + "https://en.wikipedia.org", "https://commons.wikimedia.org", + "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", + "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", + "https://geocoding-api.open-meteo.com", + ], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], - objectSrc: ["'self'"], - frameSrc: ["'self'"], + objectSrc: ["'none'"], + frameSrc: ["'none'"], frameAncestors: ["'self'"], upgradeInsecureRequests: shouldForceHttps ? [] : null } @@ -224,9 +232,13 @@ if (process.env.NODE_ENV === 'production') { }); } -// Global error handler +// Global error handler — do not leak stack traces in production app.use((err: Error, req: Request, res: Response, next: NextFunction) => { - console.error('Unhandled error:', err); + if (process.env.NODE_ENV !== 'production') { + console.error('Unhandled error:', err); + } else { + console.error('Unhandled error:', err.message); + } res.status(500).json({ error: 'Internal server error' }); }); @@ -237,6 +249,9 @@ const server = app.listen(PORT, () => { console.log(`TREK API running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`); + if (JWT_SECRET_IS_GENERATED) { + console.warn('[SECURITY WARNING] JWT_SECRET was auto-generated. Sessions will not persist across restarts. Set JWT_SECRET env var for production use.'); + } if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.'); diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 36a3e35..e6787d8 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -275,7 +275,9 @@ router.post('/:id/link', authenticate, (req: Request, res: Response) => { db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run( id, reservation_id || null, assignment_id || null, place_id || null ); - } catch {} + } catch (err) { + console.error('[Files] Error creating file link:', err instanceof Error ? err.message : err); + } const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id); res.json({ success: true, links }); diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 76b81bc..a4370a9 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -426,7 +426,8 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const attribution = photo.authorAttributions?.[0]?.displayName || null; const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true` + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&skipHttpRedirect=true`, + { headers: { 'X-Goog-Api-Key': apiKey } } ); const mediaData = await mediaRes.json() as { photoUri?: string }; const photoUrl = mediaData.photoUri;