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:
2026-03-27 08:47:24 -06:00
parent 39d74e7b85
commit 1bf19fff0e
34 changed files with 1128 additions and 155 deletions

View File

@@ -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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}
`;
}

View File

@@ -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 || '',

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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) => {

View File

@@ -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([

View File

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

View File

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

View File

@@ -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" */

View File

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

View File

@@ -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) {

View File

@@ -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',

View File

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