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:
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user