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

View File

@ -344,6 +344,9 @@ ENABLE_CHAT=false
ROCKETCHAT_ADMIN_USER=rcadmin
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
# MongoDB credentials for Rocket.Chat (required — MongoDB runs with --auth)
MONGO_ROOT_USER=rocketchat
MONGO_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# --- Gancio (Event Management) ---
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)

View File

@ -48,10 +48,11 @@ export default function ChatPanel({ panel, leftOffset }: Props) {
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
if (!iframeRef.current?.contentWindow || !rcServiceUrl) return;
const targetOrigin = new URL(rcServiceUrl).origin;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: rcAuthToken },
'*',
targetOrigin,
);
};

View File

@ -30,7 +30,9 @@ export default function LoginPage() {
const [forgotSent, setForgotSent] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const redirectTo = searchParams.get('redirect');
const rawRedirect = searchParams.get('redirect');
// Validate redirect is a safe relative path (prevents open redirect attacks)
const redirectTo = rawRedirect && rawRedirect.startsWith('/') && !rawRedirect.startsWith('//') ? rawRedirect : null;
const refCode = searchParams.get('ref') || '';
const showRegister = settings?.enablePublicRegistration !== false;

View File

@ -78,10 +78,11 @@ export default function RocketChatPage() {
if (!authToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
if (!iframeRef.current?.contentWindow || !serviceUrl) return;
const targetOrigin = new URL(serviceUrl).origin;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken },
'*',
targetOrigin,
);
};

View File

@ -9,6 +9,7 @@ import {
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import DOMPurify from 'dompurify';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
@ -262,7 +263,7 @@ export default function TicketedEventDetailPage() {
{/* Description */}
{event.richDescription ? (
<Card style={{ marginBottom: 24 }}>
<div dangerouslySetInnerHTML={{ __html: event.richDescription }} />
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(event.richDescription) }} />
</Card>
) : event.description ? (
<Card style={{ marginBottom: 24 }}>

View File

@ -68,10 +68,18 @@ export default function VolunteerChatPage() {
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken },
'*',
);
// Derive target origin from the Rocket.Chat service URL for security
try {
if (!rcConfig?.subdomain || !rcConfig?.domain || !rcConfig?.embedPort) return;
const rcUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
const targetOrigin = rcUrl ? new URL(rcUrl).origin : '*';
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken },
targetOrigin,
);
} catch {
// Fallback: don't send if we can't determine origin
}
};
sendToken();

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

View File

@ -20,7 +20,7 @@ scrape_configs:
- job_name: 'changemaker-v2-api'
static_configs:
- targets: ['changemaker-v2-api:4000']
metrics_path: '/api/metrics'
metrics_path: '/api/metrics/internal'
scrape_interval: 10s
scrape_timeout: 5s

View File

@ -858,7 +858,7 @@ services:
condition: service_started
environment:
- ROOT_URL=http://chat.${DOMAIN:-cmlite.org}
- MONGO_URL=mongodb://mongodb-rocketchat:27017/rocketchat?replicaSet=rs0
- MONGO_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/rocketchat?replicaSet=rs0&authSource=admin
- MONGO_OPLOG_URL=mongodb://mongodb-rocketchat:27017/local?replicaSet=rs0
- TRANSPORTER=monolith+nats://nats-rocketchat:4222
- PORT=3000
@ -908,14 +908,17 @@ services:
image: mongo:6.0
container_name: mongodb-rocketchat
restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
volumes:
- mongodb-rocketchat-data:/data/db
logging: *default-logging
networks:
- changemaker-lite
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status().ok } catch(e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb-rocketchat:27017'}]}).ok }"]
test: ["CMD", "mongosh", "-u", "${MONGO_ROOT_USER:-rocketchat}", "-p", "${MONGO_ROOT_PASSWORD}", "--authenticationDatabase", "admin", "--quiet", "--eval", "try { rs.status().ok } catch(e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb-rocketchat:27017'}]}).ok }"]
interval: 10s
timeout: 10s
retries: 10
@ -1205,8 +1208,7 @@ services:
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_AUTH_ANONYMOUS_ENABLED=false
volumes:
- grafana-data:/var/lib/grafana
- ./configs/grafana:/etc/grafana/provisioning

43
nginx/conf.d/api.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name api.cmlite.org api.betteredmonton.org api.pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always;
# Media API endpoints (must come BEFORE / for longest prefix match)
location /media/ {
limit_req zone=api_global burst=60 nodelay;
set $upstream_media http://changemaker-media-api:4100/api/;
proxy_pass $upstream_media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Large upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Main API (Express) — includes WebSocket upgrade for docs collaboration
location / {
limit_req zone=api_global burst=60 nodelay;
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# WebSocket support (docs collaboration via Hocuspocus)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

731
nginx/conf.d/services.conf Normal file
View File

@ -0,0 +1,731 @@
# Gitea — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name git.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
# Increase max body size for large git pushes (2GB)
client_max_body_size 2048M;
location / {
set $upstream_gitea http://gitea-changemaker:3000;
proxy_pass $upstream_gitea;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# n8n — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name n8n.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_n8n http://n8n-changemaker:5678;
proxy_pass $upstream_n8n;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Grafana — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name grafana.cmlite.org grafana.betteredmonton.org grafana.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_grafana http://grafana-changemaker:3000;
proxy_pass $upstream_grafana;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# NocoDB (data browser) — allows iframe embedding from admin
server {
listen 80;
server_name db.cmlite.org db.betteredmonton.org db.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_nocodb http://changemaker-v2-nocodb:8080;
proxy_pass $upstream_nocodb;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Listmonk — via auth proxy, allows iframe embedding from admin
server {
listen 80;
server_name listmonk.cmlite.org listmonk.betteredmonton.org listmonk.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org app.pridecorner.ca" always;
location / {
set $upstream_listmonk http://changemaker-v2-api:9002;
proxy_pass $upstream_listmonk;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# MkDocs — allows iframe embedding from admin
server {
listen 80;
server_name docs.cmlite.org docs.betteredmonton.org docs.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_mkdocs http://mkdocs-changemaker:8000;
proxy_pass $upstream_mkdocs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Code Server — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name code.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_code http://code-server-changemaker:8443;
proxy_pass $upstream_code;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# MailHog (email testing) — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name mail.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_mailhog http://mailhog-changemaker:8025;
proxy_pass $upstream_mailhog;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for MailHog live updates
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Mini QR — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name qr.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_miniqr http://mini-qr:8080;
proxy_pass $upstream_miniqr;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Excalidraw — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name draw.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_excalidraw http://excalidraw-changemaker:80;
proxy_pass $upstream_excalidraw;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for collaboration
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# Vaultwarden (password manager) — allows iframe embedding from admin
server {
listen 80;
server_name vault.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_vaultwarden http://vaultwarden-changemaker:80;
proxy_pass $upstream_vaultwarden;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# Rocket.Chat (team chat) — allows iframe embedding from admin
server {
listen 80;
server_name chat.cmlite.org chat.betteredmonton.org chat.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_rocketchat http://rocketchat-changemaker:3000;
proxy_pass $upstream_rocketchat;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (critical for RC real-time messaging)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
client_max_body_size 100m;
}
}
# Gancio (event management) — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name events.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_gancio http://gancio-changemaker:13120;
proxy_pass $upstream_gancio;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org)
server {
listen 80;
server_name meet.cmlite.org meet.betteredmonton.org meet.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_jitsi http://jitsi-web-changemaker:80;
proxy_pass $upstream_jitsi;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# --- Embed proxy ports (for iframe embedding without DNS/subdomain) ---
# These listen on dedicated ports so the admin GUI can iframe services via
# localhost:PORT, bypassing X-Frame-Options without needing *.localhost DNS.
# NOTE: In Docker deployments, these ports come from env vars via the .template file.
# These hardcoded values are defaults for reference only.
server {
listen 8881;
location / {
set $upstream_nocodb http://changemaker-v2-nocodb:8080;
proxy_pass $upstream_nocodb;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 8882;
location / {
set $upstream_n8n http://n8n-changemaker:5678;
proxy_pass $upstream_n8n;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 8883;
# Increase max body size for large git pushes (2GB)
client_max_body_size 2048M;
location / {
set $upstream_gitea http://gitea-changemaker:3000;
proxy_pass $upstream_gitea;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 8884;
location / {
set $upstream_mailhog http://mailhog-changemaker:8025;
proxy_pass $upstream_mailhog;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 8885;
location / {
set $upstream_miniqr http://mini-qr:8080;
proxy_pass $upstream_miniqr;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Excalidraw embed proxy (port 8886)
server {
listen 8886;
location / {
set $upstream_excalidraw http://excalidraw-changemaker:80;
proxy_pass $upstream_excalidraw;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for collaboration
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# Admin GUI — app subdomain
server {
listen 80;
server_name app.cmlite.org app.betteredmonton.org app.pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always;
# Social media bot detection for OG meta tags
set $is_bot 0;
if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest|Embedly|Quora Link Preview|Showyoubot|outbrain|vkShare|W3C_Validator)") {
set $is_bot 1;
}
# Bot-specific rewrites — serve OG meta from API for rich social previews
location ~ ^/campaign/([^/]+)$ {
if ($is_bot) {
rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last;
}
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ ^/p/([^/]+)$ {
if ($is_bot) {
rewrite ^/p/(.+)$ /api/og/page/$1 last;
}
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location ~ ^/gallery/watch/([^/]+)$ {
if ($is_bot) {
rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last;
}
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for Vite HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Media API (direct path - used by admin GUI media-api.ts client)
# Rewrites /media/* to /api/* (matches Vite dev proxy behavior)
# Uses variable proxy_pass for runtime DNS resolution after container restarts
location /media/ {
rewrite ^/media/(.*) /api/$1 break;
set $upstream_media_app http://changemaker-media-api:4100;
proxy_pass $upstream_media_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large file upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Media API endpoints (must come BEFORE /api/ for longest prefix match)
location /api/media/ {
set $upstream_media http://changemaker-media-api:4100;
proxy_pass $upstream_media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# MkDocs proxy (docs search index for volunteer map)
location /mkdocs-proxy/ {
set $upstream_mkdocs http://mkdocs-changemaker:8000;
rewrite ^/mkdocs-proxy/(.*) /$1 break;
proxy_pass $upstream_mkdocs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API (Express)
location /api/ {
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# MkDocs built static site — root domain
server {
listen 80;
server_name cmlite.org;
location / {
set $upstream_site http://mkdocs-site-server-changemaker:80;
proxy_pass $upstream_site;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Root domain — routes to admin GUI (supports custom DOMAIN env var)
server {
listen 80;
server_name betteredmonton.org pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for Vite HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Media API (direct path - used by admin GUI media-api.ts client)
# Uses variable proxy_pass for runtime DNS resolution after container restarts
location /media/ {
rewrite ^/media/(.*) /api/$1 break;
set $upstream_media_root http://changemaker-media-api:4100;
proxy_pass $upstream_media_root;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large file upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Media API endpoints (must come BEFORE /api/ for longest prefix match)
location /api/media/ {
set $upstream_media http://changemaker-media-api:4100;
proxy_pass $upstream_media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Large upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# API (Express)
location /api/ {
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Homepage dashboard — allows iframe embedding from admin
server {
listen 80;
server_name home.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / {
set $upstream_homepage http://homepage-changemaker:3000;
proxy_pass $upstream_homepage;
proxy_hide_header X-Frame-Options;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Homepage embed proxy (port 8887)
server {
listen 8887;
location / {
set $upstream_homepage http://homepage-changemaker:3000;
proxy_pass $upstream_homepage;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Vaultwarden embed proxy (port 8890)
server {
listen 8890;
location / {
set $upstream_vaultwarden http://vaultwarden-changemaker:80;
proxy_pass $upstream_vaultwarden;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# Rocket.Chat embed proxy (port 8891)
server {
listen 8891;
location / {
set $upstream_rocketchat http://rocketchat-changemaker:3000;
proxy_pass $upstream_rocketchat;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
client_max_body_size 100m;
}
}
# Gancio embed proxy (port 8892)
server {
listen 8892;
location / {
set $upstream_gancio http://gancio-changemaker:13120;
proxy_pass $upstream_gancio;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Jitsi Meet embed proxy (port 8893)
server {
listen 8893;
location / {
set $upstream_jitsi http://jitsi-web-changemaker:80;
proxy_pass $upstream_jitsi;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
}
}
# Grafana embed proxy (port 8894)
server {
listen 8894;
location / {
set $upstream_grafana http://grafana-changemaker:3000;
proxy_pass $upstream_grafana;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Alertmanager embed proxy (port 8895)
server {
listen 8895;
location / {
set $upstream_alertmanager http://alertmanager-changemaker:9093;
proxy_pass $upstream_alertmanager;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -32,6 +32,12 @@ http {
types_hash_max_size 2048;
client_max_body_size 50m;
# Rate limiting zones (defense-in-depth alongside app-level Redis rate limits)
limit_req_zone $binary_remote_addr zone=api_global:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=2r/s;
limit_req_status 429;
# Gzip compression
gzip on;
gzip_vary on;