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
This commit is contained in:
Claude
2026-03-30 23:34:55 +00:00
parent fedd559fd6
commit 804c2586a9
3 changed files with 28 additions and 10 deletions

View File

@@ -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.');

View File

@@ -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 });

View File

@@ -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;