fix: harden deploys and close top security holes after /events outage

Isolate next dev from production .next, add build-guard/atomic deploy/health
watchdog, error boundaries, and fix JWT startup, meetup leaks, media path
traversal, SVG/memory uploads, and JSON-LD escaping.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-07-18 19:14:57 +02:00
co-authored by Cursor
parent 495289232b
commit ede8817583
22 changed files with 700 additions and 86 deletions
+6 -1
View File
@@ -91,7 +91,12 @@ router.get('/ics', async (_req: Request, res: Response) => {
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
// Public subscription feed — never expose HIDDEN or unpublished meetups.
where: {
date: { gte: cutoff },
visibility: 'PUBLIC',
status: 'PUBLISHED',
},
orderBy: { date: 'asc' },
include: { organizer: true },
});
+41 -24
View File
@@ -18,12 +18,23 @@ function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true });
}
// Stream uploads to disk — buffering up to 100MB in RAM OOMs this small VPS.
// Filename is a ULID so the original client name never touches the filesystem.
const upload = multer({
storage: multer.memoryStorage(),
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
ensureStorageDir();
cb(null, STORAGE_PATH);
},
filename: (_req, _file, cb) => {
cb(null, ulid());
},
}),
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
});
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
// SVG deliberately excluded: served as image/svg+xml it is executable markup (stored XSS).
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
@@ -54,24 +65,23 @@ router.post(
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
// Disk storage already wrote the rejected blob — remove it.
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
return;
}
const mediaType = getMediaType(file.mimetype);
if (!mediaType) {
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: 'Could not determine media type' });
return;
}
const id = ulid();
// multer.diskStorage already wrote the blob under a ULID filename.
const id = file.filename;
const slug = makeSlug(file.originalname);
ensureStorageDir();
const filePath = path.join(STORAGE_PATH, id);
fs.writeFileSync(filePath, file.buffer);
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
fs.writeFileSync(metaPath, JSON.stringify({
mimeType: file.mimetype,
@@ -79,23 +89,30 @@ router.post(
size: file.size,
}));
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
try {
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
} catch (dbErr) {
// Roll back the on-disk blob if the DB insert fails.
if (file.path) fs.unlink(file.path, () => {});
if (fs.existsSync(metaPath)) fs.unlink(metaPath, () => {});
throw dbErr;
}
} catch (err) {
console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' });
+46 -1
View File
@@ -1,13 +1,47 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
const meetupInclude = { organizer: true } as const;
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so public listings stay open while staff
// can still see HIDDEN/DRAFT meetups. Mirrors the pattern in api/posts.ts.
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice(7);
try {
if (looksLikeApiKey(token)) {
return await resolveApiKey(token);
}
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
return await authService.resolveAccess(payload.pubkey);
} catch {
return null;
}
}
// True when the requester may see non-public meetups (HIDDEN / drafts).
async function canViewHiddenMeetups(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return (
!!access &&
(access.isSuperAdmin ||
access.permissions.has('events.edit') ||
access.permissions.has('events.create'))
);
}
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -28,7 +62,8 @@ async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
// `admin=true` reveals HIDDEN meetups, so honor it only for authorized staff.
const admin = req.query.admin === 'true' && (await canViewHiddenMeetups(req));
const organizerSlug = req.query.organizerSlug as string | undefined;
const where: Record<string, unknown> = {};
if (status) where.status = status;
@@ -69,6 +104,16 @@ router.get('/:id', async (req: Request, res: Response) => {
return;
}
// Don't leak HIDDEN / unpublished meetups to the public; treat as not-found.
const staff = await canViewHiddenMeetups(req);
if (
!staff &&
(meetup.visibility !== 'PUBLIC' || meetup.status !== 'PUBLISHED')
) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
+18
View File
@@ -32,6 +32,24 @@ const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// Refuse to boot in production with a missing/weak JWT secret — otherwise anyone
// could forge admin JWTs against the shipped `change-me-in-production` default.
if (process.env.NODE_ENV === 'production') {
const secret = process.env.JWT_SECRET;
if (!secret || secret === 'change-me-in-production' || secret.length < 32) {
console.error(
'FATAL: JWT_SECRET must be set to a strong value (>= 32 chars) in production. Refusing to start.'
);
process.exit(1);
}
}
// A rejected promise that escapes a handler should be logged, not silently crash
// (or, on older Express, hang) the process.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
});
// Trust the first proxy (nginx) so req.ip returns the real client IP
app.set('trust proxy', 1);
+15 -9
View File
@@ -30,17 +30,23 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
const token = header.slice(7);
// API keys authenticate programmatic clients. They carry their own scoped
// permission set rather than a role, so we pre-resolve access here.
// permission set rather than a role, so we pre-resolve access here. Wrapped in
// try/catch because Express 4 does not catch rejections from async middleware.
if (looksLikeApiKey(token)) {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
try {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
} catch (err) {
console.error('API key resolution error:', err);
res.status(500).json({ error: 'Internal server error' });
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
return;
}