Security audit: fix 30 findings across auth, IDOR, XSS, path traversal, infrastructure
Comprehensive 6-domain security audit addressing 8 Critical, 17 Important,
and 5 Low findings. Key fixes:
Critical:
- Strip PII from unauthenticated ticket lookup (IDOR)
- Add role+permission checks to event check-in routes
- Validate tier-to-event ownership on update/delete (IDOR)
- Fix path traversal in video replace (resolve + prefix check)
- Enable MongoDB authentication for Rocket.Chat
- Disable Grafana anonymous access
- Sanitize CSV exports against formula injection (payments)
- Apply DOMPurify to richDescription on public event page (XSS)
Important:
- Require current password for self-service password changes
- Atomic password reset token consumption (race condition fix)
- Scope postMessage to specific origin (not wildcard)
- Validate redirect parameter against open redirect
- Replace weak temp passwords (5760 values → crypto.randomBytes)
- Move shift capacity check inside transaction (TOCTOU fix)
- Fix EVENTS_ADMIN privilege inversion in ticketed events
- Make ENCRYPTION_KEY required (remove optional fallback)
- Add internal Prometheus metrics endpoint for Docker scraping
- Add nginx-level rate limiting (limit_req_zone)
- Fix X-Forwarded-For to use $remote_addr (prevents spoofing)
- Replace CSP stripping with frame-ancestors in embed proxies
- Remove error.message from Fastify 500 responses
- Strip PII from volunteer canvass address data
- Wrap GrapesJS output in {% raw %} to prevent Jinja2 SSTI
- Scope SSE token query param to /sse path only
- Sanitize Listmonk email query against injection
Bunker Admin
This commit is contained in:
@@ -35,8 +35,8 @@ const envSchema = z.object({
|
||||
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
||||
|
||||
// Encryption (for DB-stored secrets like SMTP password; falls back to JWT_ACCESS_SECRET)
|
||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters').optional(),
|
||||
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||
|
||||
@@ -186,32 +186,45 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
const result = await passwordResetTokenService.validateToken(token);
|
||||
|
||||
if (!result.valid || !result.userId) {
|
||||
res.status(400).json({
|
||||
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash password BEFORE token validation to minimize race window
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update password, mark token used, invalidate all refresh tokens — all in one transaction
|
||||
// Atomic: validate + consume token + reset password in one transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: result.userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
|
||||
await tx.passwordResetToken.update({
|
||||
where: { token },
|
||||
// Atomically mark token as used (only if valid and not already used)
|
||||
const updated = await tx.passwordResetToken.updateMany({
|
||||
where: {
|
||||
token,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new Error('INVALID_TOKEN');
|
||||
}
|
||||
|
||||
// Get the userId from the consumed token
|
||||
const record = await tx.passwordResetToken.findUnique({ where: { token } });
|
||||
if (!record) throw new Error('INVALID_TOKEN');
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
await tx.refreshToken.deleteMany({ where: { userId: record.userId } });
|
||||
});
|
||||
|
||||
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'INVALID_TOKEN') {
|
||||
res.status(400).json({
|
||||
error: { message: 'Invalid or expired reset token', code: 'INVALID_TOKEN' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +403,12 @@ export const effectivenessService = {
|
||||
const from = dateFilter?.gte || defaultFrom;
|
||||
const to = dateFilter?.lte || new Date();
|
||||
|
||||
const truncFnSql = Prisma.raw(`'${truncFn}'`);
|
||||
// Use a lookup map instead of Prisma.raw() to prevent SQL injection if enum changes
|
||||
const truncFnMap: Record<string, ReturnType<typeof Prisma.sql>> = {
|
||||
day: Prisma.sql`'day'`,
|
||||
week: Prisma.sql`'week'`,
|
||||
};
|
||||
const truncFnSql = truncFnMap[truncFn] || truncFnMap.day;
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
|
||||
@@ -96,6 +96,15 @@ async function annotateAddressesWithVisits(
|
||||
const ADMIN_ADDRESS_FIELDS = ['firstName', 'lastName', 'unitNumber', 'email', 'phone'] as const;
|
||||
const VOLUNTEER_ADDRESS_FIELDS = ['supportLevel', 'sign', 'signSize', 'notes'] as const;
|
||||
|
||||
/** Strip PII fields from address data for non-admin volunteer access */
|
||||
function stripAddressPii(addresses: any[]): any[] {
|
||||
return addresses.map(({ firstName, lastName, email, phone, ...safe }) => ({
|
||||
...safe,
|
||||
// Volunteers see support data but not personal contact info
|
||||
hasContactInfo: !!(firstName || lastName || email || phone),
|
||||
}));
|
||||
}
|
||||
|
||||
export const canvassService = {
|
||||
// ─── Volunteer Methods ─────────────────────────────────────────────
|
||||
|
||||
@@ -432,7 +441,8 @@ export const canvassService = {
|
||||
const durationSeconds = (Date.now() - startTime) / 1000;
|
||||
recordLocationQuery('canvass_cut', !!bounds, result.length, durationSeconds);
|
||||
|
||||
return result;
|
||||
// Strip PII for volunteer access (firstName, lastName, email, phone)
|
||||
return stripAddressPii(result);
|
||||
},
|
||||
|
||||
async getAllLocationsForCanvass(
|
||||
@@ -521,7 +531,8 @@ export const canvassService = {
|
||||
const durationSeconds = (Date.now() - startTime) / 1000;
|
||||
recordLocationQuery('canvass_all', !!bounds, result.length, durationSeconds);
|
||||
|
||||
return result;
|
||||
// Strip PII for volunteer access (firstName, lastName, email, phone)
|
||||
return stripAddressPii(result);
|
||||
},
|
||||
|
||||
async updateAddressAsVolunteer(
|
||||
|
||||
@@ -26,14 +26,9 @@ import type {
|
||||
PublicSignupInput,
|
||||
} from './shifts.schemas';
|
||||
|
||||
const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
|
||||
const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
|
||||
|
||||
function generateReadablePassword(): string {
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const num = Math.floor(Math.random() * 90) + 10;
|
||||
return `${adj}${noun}${num}`;
|
||||
// Generate a cryptographically strong random password (128 bits of entropy)
|
||||
return crypto.randomBytes(16).toString('base64url');
|
||||
}
|
||||
|
||||
const meetingSelect = {
|
||||
@@ -427,6 +422,7 @@ export const shiftsService = {
|
||||
throw new AppError(400, 'This shift has already passed', 'SHIFT_PAST');
|
||||
}
|
||||
|
||||
// Pre-check capacity (definitive check is inside transaction below)
|
||||
if (shift.currentVolunteers >= shift.maxVolunteers) {
|
||||
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
|
||||
}
|
||||
@@ -468,50 +464,50 @@ export const shiftsService = {
|
||||
isNewUser = true;
|
||||
}
|
||||
|
||||
// Create signup (or re-activate cancelled one)
|
||||
let signup;
|
||||
if (existingSignup && existingSignup.status === SignupStatus.CANCELLED) {
|
||||
[signup] = await prisma.$transaction([
|
||||
prisma.shiftSignup.update({
|
||||
// Atomic signup + capacity check inside transaction to prevent TOCTOU race
|
||||
const signup = await prisma.$transaction(async (tx) => {
|
||||
// Re-check capacity atomically inside the transaction
|
||||
const currentShift = await tx.shift.findUnique({ where: { id: shiftId } });
|
||||
if (!currentShift || currentShift.currentVolunteers >= currentShift.maxVolunteers) {
|
||||
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
|
||||
}
|
||||
|
||||
let created;
|
||||
if (existingSignup && existingSignup.status === SignupStatus.CANCELLED) {
|
||||
created = await tx.shiftSignup.update({
|
||||
where: { id: existingSignup.id },
|
||||
data: {
|
||||
status: SignupStatus.CONFIRMED,
|
||||
signupSource: user ? SignupSource.AUTHENTICATED : SignupSource.PUBLIC,
|
||||
userName: data.name,
|
||||
userPhone: data.phone,
|
||||
userId: user.id,
|
||||
userId: user!.id,
|
||||
},
|
||||
}),
|
||||
prisma.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: {
|
||||
currentVolunteers: { increment: 1 },
|
||||
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
[signup] = await prisma.$transaction([
|
||||
prisma.shiftSignup.create({
|
||||
});
|
||||
} else {
|
||||
created = await tx.shiftSignup.create({
|
||||
data: {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
userId: user.id,
|
||||
shiftTitle: currentShift.title,
|
||||
userId: user!.id,
|
||||
userEmail: data.email,
|
||||
userName: data.name,
|
||||
userPhone: data.phone,
|
||||
signupSource: isNewUser ? SignupSource.PUBLIC : SignupSource.AUTHENTICATED,
|
||||
},
|
||||
}),
|
||||
prisma.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: {
|
||||
currentVolunteers: { increment: 1 },
|
||||
status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await tx.shift.update({
|
||||
where: { id: shiftId },
|
||||
data: {
|
||||
currentVolunteers: { increment: 1 },
|
||||
status: currentShift.currentVolunteers + 1 >= currentShift.maxVolunteers ? ShiftStatus.FULL : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
// Send confirmation email
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { join, resolve } from 'path';
|
||||
import { lookup } from 'mime-types';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
@@ -204,14 +204,13 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
// Validate path doesn't contain traversal attempts
|
||||
if (video.thumbnailPath.includes('..')) {
|
||||
logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
|
||||
// Validate path is within allowed media directory
|
||||
const thumbnailPath = resolve(video.thumbnailPath);
|
||||
if (!thumbnailPath.startsWith(resolve('/media/local'))) {
|
||||
logger.warn(`Path traversal attempt detected: ${thumbnailPath}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
const thumbnailPath = video.thumbnailPath;
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
await access(thumbnailPath);
|
||||
@@ -304,16 +303,15 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate path doesn't contain traversal attempts
|
||||
if (video.path.includes('..') || video.filename.includes('..')) {
|
||||
logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
// Construct full file path
|
||||
const filePath = video.path.endsWith(video.filename)
|
||||
// Validate path is within allowed media directory
|
||||
const candidatePath = video.path.endsWith(video.filename)
|
||||
? video.path
|
||||
: join(video.path, video.filename);
|
||||
const filePath = resolve(candidatePath);
|
||||
if (!filePath.startsWith(resolve('/media/local'))) {
|
||||
logger.warn(`Path traversal attempt detected: ${filePath}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '../../../utils/logger';
|
||||
import { sign } from 'jsonwebtoken';
|
||||
import { env } from '../../../config/env';
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join, dirname, basename, extname, normalize } from 'path';
|
||||
import { join, dirname, basename, extname, normalize, resolve } from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const UpdateVideoSchema = z.object({
|
||||
@@ -176,13 +176,14 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data;
|
||||
|
||||
// Path traversal protection
|
||||
// Path traversal protection: resolve against allowed base and verify containment
|
||||
const MEDIA_BASE = '/media/local';
|
||||
if (newPath.includes('\0') || newFilename.includes('\0')) {
|
||||
return reply.code(400).send({ message: 'Invalid file path' });
|
||||
}
|
||||
const normalizedPath = normalize(newPath);
|
||||
if (normalizedPath.includes('..') || normalizedPath.startsWith('/') || normalizedPath.startsWith('\\')) {
|
||||
return reply.code(400).send({ message: 'Invalid file path: must be relative with no traversal' });
|
||||
const resolvedPath = resolve(MEDIA_BASE, newPath);
|
||||
if (!resolvedPath.startsWith(resolve(MEDIA_BASE))) {
|
||||
return reply.code(400).send({ message: 'Invalid file path: must be within media directory' });
|
||||
}
|
||||
const sanitizedFilename = basename(newFilename);
|
||||
|
||||
@@ -199,7 +200,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
const updatedVideo = await prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: {
|
||||
path: normalizedPath,
|
||||
path: resolvedPath,
|
||||
filename: sanitizedFilename,
|
||||
originalPath: existingVideo.path, // Save old path for reference
|
||||
originalFilename: existingVideo.filename,
|
||||
@@ -245,11 +246,18 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { startDate, endDate } = request.query;
|
||||
|
||||
// Validate date parameters
|
||||
const parsedStart = startDate ? new Date(startDate) : undefined;
|
||||
const parsedEnd = endDate ? new Date(endDate) : undefined;
|
||||
if ((parsedStart && isNaN(parsedStart.getTime())) || (parsedEnd && isNaN(parsedEnd.getTime()))) {
|
||||
return reply.code(400).send({ message: 'Invalid date format for startDate or endDate' });
|
||||
}
|
||||
|
||||
try {
|
||||
const analytics = await videoAnalyticsService.getVideoAnalytics(
|
||||
videoId,
|
||||
startDate ? new Date(startDate) : undefined,
|
||||
endDate ? new Date(endDate) : undefined
|
||||
parsedStart,
|
||||
parsedEnd,
|
||||
);
|
||||
|
||||
return analytics;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import { access, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { join, resolve } from 'path';
|
||||
import { lookup } from 'mime-types';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UserRole, UserStatus } from '@prisma/client';
|
||||
@@ -110,17 +110,16 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(404).send({ message: 'Video not found or not published' });
|
||||
}
|
||||
|
||||
// Security: Validate path doesn't contain traversal attempts
|
||||
if (video.path.includes('..') || video.filename.includes('..')) {
|
||||
logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
// Construct full file path
|
||||
// Handle both new format (full path) and legacy format (directory only)
|
||||
const filePath = video.path.endsWith(video.filename)
|
||||
// Security: Validate path is within allowed media directory
|
||||
const MEDIA_BASE = '/media/local';
|
||||
const candidatePath = video.path.endsWith(video.filename)
|
||||
? video.path
|
||||
: join(video.path, video.filename);
|
||||
const filePath = resolve(candidatePath);
|
||||
if (!filePath.startsWith(resolve(MEDIA_BASE))) {
|
||||
logger.warn(`Path traversal attempt detected: ${filePath}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
@@ -216,22 +215,23 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
// Security: Validate path
|
||||
if (video.thumbnailPath.includes('..')) {
|
||||
logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
|
||||
// Security: Validate path is within allowed media directory
|
||||
const resolvedThumb = resolve(video.thumbnailPath);
|
||||
if (!resolvedThumb.startsWith(resolve('/media/local'))) {
|
||||
logger.warn(`Path traversal attempt detected: ${resolvedThumb}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
await access(video.thumbnailPath);
|
||||
await access(resolvedThumb);
|
||||
} catch {
|
||||
logger.error(`Thumbnail file not found on disk: ${video.thumbnailPath}`);
|
||||
logger.error(`Thumbnail file not found on disk: ${resolvedThumb}`);
|
||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
const mimeType = lookup(video.thumbnailPath) || 'image/jpeg';
|
||||
const mimeType = lookup(resolvedThumb) || 'image/jpeg';
|
||||
|
||||
// Read and send thumbnail
|
||||
const thumbnailBuffer = await readFile(video.thumbnailPath);
|
||||
|
||||
@@ -210,7 +210,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error publishing video ${videoId}:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to publish video', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to publish video' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -236,7 +236,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error unpublishing video ${videoId}:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to unpublish video', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to unpublish video' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -268,7 +268,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, count: result.count };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error bulk publishing videos:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to publish videos', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to publish videos' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -299,7 +299,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, count: result.count };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error bulk unpublishing videos:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to unpublish videos', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to unpublish videos' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -326,7 +326,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error locking video ${videoId}:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to lock video', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to lock video' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -352,7 +352,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error unlocking video ${videoId}:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to unlock video', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to unlock video' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -402,7 +402,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return { success: true, video: updatedVideo };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error generating thumbnail for video ${videoId}:`, error);
|
||||
return reply.code(500).send({ message: 'Failed to generate thumbnail', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to generate thumbnail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -473,7 +473,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Error bulk generating thumbnails:', error);
|
||||
return reply.code(500).send({ message: 'Failed to bulk generate thumbnails', error: error.message });
|
||||
return reply.code(500).send({ message: 'Failed to bulk generate thumbnails' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -103,10 +103,13 @@ function validateStubPath(stubPath: string): void {
|
||||
|
||||
function wrapInMaterialOverride(html: string, css: string | null): string {
|
||||
const styleBlock = css ? `<style>\n${css}\n</style>` : '';
|
||||
// Wrap dynamic content in {% raw %} to prevent Jinja2 SSTI via admin-authored HTML/CSS
|
||||
return `{% extends "main.html" %}
|
||||
{% block content %}
|
||||
{% raw %}
|
||||
${styleBlock}
|
||||
${html}
|
||||
{% endraw %}
|
||||
{% endblock %}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { paymentSettingsService } from './payment-settings.service';
|
||||
import { stringify } from 'csv-stringify/sync';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/** Sanitize a string to prevent CSV formula injection */
|
||||
function sanitizeCsvValue(value: string): string {
|
||||
if (!value) return value;
|
||||
const dangerous = ['=', '+', '-', '@', '\t', '\r'];
|
||||
if (dangerous.some(c => value.startsWith(c))) {
|
||||
return `'${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const donationsService = {
|
||||
/** Create a Stripe Checkout session for a donation */
|
||||
async createDonationCheckout(
|
||||
@@ -170,12 +180,12 @@ export const donationsService = {
|
||||
|
||||
return stringify(orders.map((o) => ({
|
||||
'Date': o.createdAt.toISOString(),
|
||||
'Donor Name': o.isAnonymous ? 'Anonymous' : (o.buyerName || ''),
|
||||
'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''),
|
||||
'Donor Name': sanitizeCsvValue(o.isAnonymous ? 'Anonymous' : (o.buyerName || '')),
|
||||
'Donor Email': sanitizeCsvValue(o.isAnonymous ? '' : (o.buyerEmail || '')),
|
||||
'Amount (CAD)': (o.amountCAD / 100).toFixed(2),
|
||||
'Status': o.status,
|
||||
'Donation Page': o.donationPage?.title || 'General',
|
||||
'Message': o.donorMessage || '',
|
||||
'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
|
||||
'Message': sanitizeCsvValue(o.donorMessage || ''),
|
||||
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
|
||||
'Stripe Payment Intent': o.stripePaymentIntentId || '',
|
||||
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
|
||||
|
||||
@@ -353,6 +353,17 @@ router.post(
|
||||
|
||||
// =================== CSV Export ===================
|
||||
|
||||
/** Sanitize a CSV field to prevent formula injection (=, +, -, @, \t, \r) */
|
||||
function sanitizeCsvField(value: string): string {
|
||||
if (!value) return '';
|
||||
const dangerous = ['=', '+', '-', '@', '\t', '\r'];
|
||||
let sanitized = value.replace(/"/g, '""');
|
||||
if (dangerous.some(c => sanitized.startsWith(c))) {
|
||||
sanitized = `'${sanitized}`;
|
||||
}
|
||||
return `"${sanitized}"`;
|
||||
}
|
||||
|
||||
// GET /api/payments/admin/export
|
||||
router.get('/export', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -369,9 +380,9 @@ router.get('/export', async (_req: Request, res: Response, next: NextFunction) =
|
||||
o.createdAt.toISOString(),
|
||||
o.type,
|
||||
(o.amountCAD / 100).toFixed(2),
|
||||
`"${o.buyerEmail}"`,
|
||||
`"${o.buyerName || ''}"`,
|
||||
`"${o.product?.title || ''}"`,
|
||||
sanitizeCsvField(o.buyerEmail || ''),
|
||||
sanitizeCsvField(o.buyerName || ''),
|
||||
sanitizeCsvField(o.product?.title || ''),
|
||||
o.status,
|
||||
].join(','));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { logger } from '../../utils/logger';
|
||||
import type { SubscriptionStatus, Prisma } from '@prisma/client';
|
||||
import { stringify } from 'csv-stringify/sync';
|
||||
|
||||
/** Sanitize a string to prevent CSV formula injection */
|
||||
function sanitizeCsvValue(value: string): string {
|
||||
if (!value) return value;
|
||||
const dangerous = ['=', '+', '-', '@', '\t', '\r'];
|
||||
if (dangerous.some(c => value.startsWith(c))) {
|
||||
return `'${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const subscriptionsService = {
|
||||
/** Create a Stripe Checkout session for a subscription */
|
||||
async createCheckoutSession(userId: string, planId: number, frequency: 'monthly' | 'yearly') {
|
||||
@@ -240,9 +250,9 @@ export const subscriptionsService = {
|
||||
});
|
||||
|
||||
return stringify(subscriptions.map((s) => ({
|
||||
'User Name': s.user?.name || '',
|
||||
'User Email': s.user?.email || '',
|
||||
'Plan': s.plan?.name || '',
|
||||
'User Name': sanitizeCsvValue(s.user?.name || ''),
|
||||
'User Email': sanitizeCsvValue(s.user?.email || ''),
|
||||
'Plan': sanitizeCsvValue(s.plan?.name || ''),
|
||||
'Price (CAD/mo)': s.plan ? (s.plan.priceCAD / 100).toFixed(2) : '',
|
||||
'Status': s.status,
|
||||
'Started': s.startDate.toISOString(),
|
||||
|
||||
@@ -24,9 +24,9 @@ import { challengeRouter } from './challenge.routes';
|
||||
const router = Router();
|
||||
|
||||
// EventSource (SSE) doesn't support custom headers — accept token via query param
|
||||
// This runs before authenticate so the SSE connection can be established
|
||||
// Scoped to /sse path only to limit token-in-URL exposure to where it's truly needed
|
||||
router.use((req, _res, next) => {
|
||||
if (req.query.token && !req.headers.authorization) {
|
||||
if (req.query.token && !req.headers.authorization && req.path.startsWith('/sse')) {
|
||||
req.headers.authorization = `Bearer ${req.query.token}`;
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { requireNonTemp } from '../../middleware/rbac.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { ticketsService } from './tickets.service';
|
||||
import { validateTokenSchema, confirmCheckinSchema, manualCheckinSchema } from './ticketed-events.schemas';
|
||||
import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All check-in routes require authentication
|
||||
router.use(authenticate);
|
||||
/** Require EVENTS_ROLES or canCreateTicketedEvents permission for check-in */
|
||||
async function requireCheckinPermission(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) return next(new AppError(401, 'Authentication required', 'AUTH_REQUIRED'));
|
||||
|
||||
const userRoles = req.user.roles || [req.user.role];
|
||||
if (userRoles.some(r => EVENTS_ROLES.includes(r as UserRole))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check user permissions
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { permissions: true },
|
||||
});
|
||||
const perms = (user?.permissions as Record<string, unknown>) || {};
|
||||
if (perms.canCreateTicketedEvents) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(new AppError(403, 'Insufficient permissions for check-in operations', 'FORBIDDEN'));
|
||||
}
|
||||
|
||||
// All check-in routes require authentication + non-temp + events permission
|
||||
router.use(authenticate, requireNonTemp, requireCheckinPermission);
|
||||
|
||||
// POST /validate — validate QR token (preview without marking checked in)
|
||||
router.post('/validate', validate(validateTokenSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -169,7 +169,11 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
|
||||
// PUT /:id/tiers/:tierId
|
||||
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tier = await ticketedEventsService.updateTier(req.params.tierId as string, req.body);
|
||||
const tier = await ticketedEventsService.updateTier(
|
||||
req.params.tierId as string,
|
||||
req.body,
|
||||
req.params.id as string,
|
||||
);
|
||||
res.json(tier);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -177,7 +181,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
|
||||
// DELETE /:id/tiers/:tierId
|
||||
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketedEventsService.deleteTier(req.params.tierId as string);
|
||||
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -232,7 +236,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
|
||||
const crypto = await import('crypto');
|
||||
const { env: envConfig } = await import('../../config/env');
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY || envConfig.JWT_ACCESS_SECRET);
|
||||
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY);
|
||||
hmac.update(ticket.id);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
|
||||
@@ -246,13 +246,41 @@ router.post('/:slug/register', optionalAuth, validate(registerFreeSchema), async
|
||||
});
|
||||
|
||||
// GET /:slug/ticket/:ticketCode — ticket confirmation page data
|
||||
router.get('/:slug/ticket/:ticketCode', async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Requires authentication or matching holder email to prevent PII enumeration
|
||||
router.get('/:slug/ticket/:ticketCode', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await ticketsService.findByCode(req.params.ticketCode as string);
|
||||
if (ticket.event.slug !== req.params.slug) {
|
||||
throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
|
||||
}
|
||||
// Generate QR URL from ticket code (check-in scanner handles code-based lookup)
|
||||
|
||||
// Only return full details if user is authenticated and is the ticket holder or an admin
|
||||
const isHolder = req.user && (
|
||||
req.user.id === ticket.userId ||
|
||||
req.user.email === ticket.holderEmail
|
||||
);
|
||||
const userRoles = req.user?.roles || (req.user?.role ? [req.user.role] : []);
|
||||
const isAdmin = userRoles.some((r: string) => ['SUPER_ADMIN', 'EVENTS_ADMIN'].includes(r));
|
||||
|
||||
if (!isHolder && !isAdmin) {
|
||||
// Return minimal non-PII data for unauthenticated/unrelated users
|
||||
res.json({
|
||||
ticketCode: ticket.ticketCode,
|
||||
status: ticket.status,
|
||||
event: {
|
||||
slug: ticket.event.slug,
|
||||
title: ticket.event.title,
|
||||
date: ticket.event.date,
|
||||
startTime: ticket.event.startTime,
|
||||
endTime: ticket.event.endTime,
|
||||
venueName: ticket.event.venueName,
|
||||
},
|
||||
tier: ticket.tier,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Full details for authenticated ticket holder or admin
|
||||
const qrUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(ticket.ticketCode)}&size=300`;
|
||||
res.json({ ...ticket, qrUrl });
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generateModeratorToken } from '../jitsi/jitsi.utils';
|
||||
import { generateSlug as generateMeetingSlug } from '../../utils/slug';
|
||||
import { env } from '../../config/env';
|
||||
import crypto from 'crypto';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
@@ -31,7 +32,10 @@ function generateInviteCode(): string {
|
||||
return crypto.randomBytes(6).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
||||
// Use EVENTS_ROLES for ownership bypass checks (imported from utils/roles)
|
||||
// Previously hardcoded as ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'] which
|
||||
// missed EVENTS_ADMIN, causing a privilege inversion
|
||||
const ADMIN_ROLES = EVENTS_ROLES.map(r => r.toString());
|
||||
|
||||
/** Validate that enableMeet is on when format requires Jitsi */
|
||||
async function validateMeetFormat(format: EventFormat | string) {
|
||||
@@ -521,10 +525,15 @@ export const ticketedEventsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateTier(tierId: string, data: Record<string, unknown>) {
|
||||
async updateTier(tierId: string, data: Record<string, unknown>, eventId?: string) {
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
// Verify tier belongs to the specified event (prevents IDOR)
|
||||
if (eventId && tier.eventId !== eventId) {
|
||||
throw new AppError(403, 'Tier does not belong to this event', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
// Convert date strings
|
||||
if (typeof data.salesStartAt === 'string') data.salesStartAt = new Date(data.salesStartAt as string);
|
||||
if (typeof data.salesEndAt === 'string') data.salesEndAt = new Date(data.salesEndAt as string);
|
||||
@@ -535,12 +544,18 @@ export const ticketedEventsService = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTier(tierId: string) {
|
||||
async deleteTier(tierId: string, eventId?: string) {
|
||||
const tier = await prisma.ticketTier.findUnique({
|
||||
where: { id: tierId },
|
||||
include: { _count: { select: { tickets: true } } },
|
||||
});
|
||||
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
// Verify tier belongs to the specified event (prevents IDOR)
|
||||
if (eventId && tier.eventId !== eventId) {
|
||||
throw new AppError(403, 'Tier does not belong to this event', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
if (tier._count.tickets > 0) {
|
||||
throw new AppError(400, 'Cannot delete a tier that has sold tickets', 'HAS_TICKETS');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AppError } from '../../middleware/error-handler';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
function getEncryptionKey(): string {
|
||||
return env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
|
||||
return env.ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
/** Generate a human-readable ticket code like "ABCD-1234" */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { UserRole, UserStatus } from '@prisma/client';
|
||||
import { usersService } from './users.service';
|
||||
import { createUserSchema, updateUserSchema, listUsersSchema } from './users.schemas';
|
||||
@@ -113,6 +114,28 @@ router.put(
|
||||
delete req.body.permissions;
|
||||
}
|
||||
|
||||
// Self-service password change requires current password verification
|
||||
if (isSelf && !isAdminUser && req.body.password) {
|
||||
if (!req.body.currentPassword) {
|
||||
res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
|
||||
return;
|
||||
}
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { password: true },
|
||||
});
|
||||
if (!currentUser) {
|
||||
res.status(404).json({ error: { message: 'User not found', code: 'NOT_FOUND' } });
|
||||
return;
|
||||
}
|
||||
const valid = await bcrypt.compare(req.body.currentPassword, currentUser.password);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: { message: 'Current password is incorrect', code: 'INVALID_CREDENTIALS' } });
|
||||
return;
|
||||
}
|
||||
delete req.body.currentPassword;
|
||||
}
|
||||
|
||||
const parsed = updateUserSchema.parse(req.body);
|
||||
const user = await usersService.update(id, parsed);
|
||||
res.json(user);
|
||||
|
||||
@@ -250,6 +250,21 @@ app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsR
|
||||
res.end(await register.metrics());
|
||||
});
|
||||
|
||||
// --- Internal Metrics Endpoint (for Prometheus scraping within Docker network) ---
|
||||
// Only accessible from Docker-internal network (nginx doesn't proxy this path externally)
|
||||
app.get('/api/metrics/internal', async (req, res) => {
|
||||
// Basic network-level protection: only allow from Docker bridge / localhost
|
||||
const remoteIp = req.ip || req.socket.remoteAddress || '';
|
||||
const isInternal = remoteIp === '127.0.0.1' || remoteIp === '::1' ||
|
||||
remoteIp.startsWith('172.') || remoteIp.startsWith('10.') || remoteIp.startsWith('192.168.');
|
||||
if (!isInternal) {
|
||||
res.status(403).json({ error: 'Internal endpoint only' });
|
||||
return;
|
||||
}
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
});
|
||||
|
||||
// --- API Routes ---
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
@@ -358,14 +373,7 @@ async function start() {
|
||||
logger.info('Database connected');
|
||||
|
||||
// Initialize encryption for DB-stored secrets (SMTP password, etc.)
|
||||
// In production, require separate encryption key (not JWT secret reuse)
|
||||
if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
|
||||
throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
|
||||
}
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
logger.warn('ENCRYPTION_KEY not set — falling back to JWT_ACCESS_SECRET for encryption. Set ENCRYPTION_KEY in production.');
|
||||
}
|
||||
initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
|
||||
initEncryption(env.ENCRYPTION_KEY);
|
||||
|
||||
// Warn if Listmonk sync is enabled but webhook secret is not configured
|
||||
if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) {
|
||||
|
||||
@@ -131,7 +131,9 @@ class ListmonkClient {
|
||||
async findSubscriberByEmail(email: string): Promise<ListmonkSubscriber | null> {
|
||||
this.assertEnabled();
|
||||
try {
|
||||
const safeEmail = email.replace(/'/g, "''");
|
||||
// Validate email format and sanitize for Listmonk query language
|
||||
// Strip all characters except valid email chars to prevent query injection
|
||||
const safeEmail = email.replace(/[^a-zA-Z0-9@._+\-]/g, '').replace(/'/g, "''");
|
||||
const query = encodeURIComponent(`subscribers.email='${safeEmail}'`);
|
||||
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
|
||||
'GET',
|
||||
|
||||
@@ -21,17 +21,20 @@ export const passwordResetTokenService = {
|
||||
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
|
||||
|
||||
// Use a generic error message for all failure cases to prevent token state enumeration
|
||||
const genericError = 'Invalid or expired reset token';
|
||||
|
||||
if (!record) {
|
||||
return { valid: false, error: 'Invalid or expired reset token' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (record.expiresAt < new Date()) {
|
||||
await prisma.passwordResetToken.delete({ where: { id: record.id } });
|
||||
return { valid: false, error: 'Reset token has expired' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (record.usedAt) {
|
||||
return { valid: false, error: 'Reset token has already been used' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
return { valid: true, userId: record.userId };
|
||||
|
||||
Reference in New Issue
Block a user