From 1bf19fff0ee4b3c6c0f8cdbf4d5fa7febce0371a Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Fri, 27 Mar 2026 08:47:24 -0600 Subject: [PATCH] Security audit: fix 30 findings across auth, IDOR, XSS, path traversal, infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 3 + admin/src/components/chat/ChatPanel.tsx | 5 +- admin/src/pages/LoginPage.tsx | 4 +- admin/src/pages/RocketChatPage.tsx | 5 +- .../pages/public/TicketedEventDetailPage.tsx | 3 +- .../src/pages/volunteer/VolunteerChatPage.tsx | 16 +- api/src/config/env.ts | 4 +- api/src/modules/auth/auth.routes.ts | 45 +- .../effectiveness/effectiveness.service.ts | 7 +- .../modules/map/canvass/canvass.service.ts | 15 +- api/src/modules/map/shifts/shifts.service.ts | 70 +- api/src/modules/media/routes/public.routes.ts | 26 +- .../media/routes/video-actions.routes.ts | 24 +- .../media/routes/video-streaming.routes.ts | 32 +- api/src/modules/media/routes/videos.routes.ts | 16 +- api/src/modules/pages/pages.service.ts | 3 + api/src/modules/payments/donations.service.ts | 18 +- .../modules/payments/payments-admin.routes.ts | 17 +- .../modules/payments/subscriptions.service.ts | 16 +- api/src/modules/social/social.routes.ts | 4 +- .../modules/ticketed-events/checkin.routes.ts | 31 +- .../ticketed-events-admin.routes.ts | 10 +- .../ticketed-events-public.routes.ts | 32 +- .../ticketed-events.service.ts | 21 +- .../ticketed-events/tickets.service.ts | 2 +- api/src/modules/users/users.routes.ts | 23 + api/src/server.ts | 24 +- api/src/services/listmonk.client.ts | 4 +- .../services/password-reset-token.service.ts | 9 +- configs/prometheus/prometheus.yml | 2 +- docker-compose.yml | 12 +- nginx/conf.d/api.conf | 43 ++ nginx/conf.d/services.conf | 731 ++++++++++++++++++ nginx/nginx.conf | 6 + 34 files changed, 1128 insertions(+), 155 deletions(-) create mode 100644 nginx/conf.d/api.conf create mode 100644 nginx/conf.d/services.conf diff --git a/.env.example b/.env.example index f772f687..a47e2331 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/admin/src/components/chat/ChatPanel.tsx b/admin/src/components/chat/ChatPanel.tsx index 58c44cbb..529cf35f 100644 --- a/admin/src/components/chat/ChatPanel.tsx +++ b/admin/src/components/chat/ChatPanel.tsx @@ -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, ); }; diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx index 7c441be8..e52cec23 100644 --- a/admin/src/pages/LoginPage.tsx +++ b/admin/src/pages/LoginPage.tsx @@ -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; diff --git a/admin/src/pages/RocketChatPage.tsx b/admin/src/pages/RocketChatPage.tsx index 1b940b8d..306e7caa 100644 --- a/admin/src/pages/RocketChatPage.tsx +++ b/admin/src/pages/RocketChatPage.tsx @@ -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, ); }; diff --git a/admin/src/pages/public/TicketedEventDetailPage.tsx b/admin/src/pages/public/TicketedEventDetailPage.tsx index ad5f11a7..9de3e50e 100644 --- a/admin/src/pages/public/TicketedEventDetailPage.tsx +++ b/admin/src/pages/public/TicketedEventDetailPage.tsx @@ -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 ? ( -
+
) : event.description ? ( diff --git a/admin/src/pages/volunteer/VolunteerChatPage.tsx b/admin/src/pages/volunteer/VolunteerChatPage.tsx index 8b3d14d1..886d6fd1 100644 --- a/admin/src/pages/volunteer/VolunteerChatPage.tsx +++ b/admin/src/pages/volunteer/VolunteerChatPage.tsx @@ -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(); diff --git a/api/src/config/env.ts b/api/src/config/env.ts index dd75c795..ac766180 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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'), diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts index 9db9ca31..80e37efa 100644 --- a/api/src/modules/auth/auth.routes.ts +++ b/api/src/modules/auth/auth.routes.ts @@ -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); } } diff --git a/api/src/modules/influence/effectiveness/effectiveness.service.ts b/api/src/modules/influence/effectiveness/effectiveness.service.ts index c5f9282a..f13d8486 100644 --- a/api/src/modules/influence/effectiveness/effectiveness.service.ts +++ b/api/src/modules/influence/effectiveness/effectiveness.service.ts @@ -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> = { + 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``; diff --git a/api/src/modules/map/canvass/canvass.service.ts b/api/src/modules/map/canvass/canvass.service.ts index b3c3c017..851f0d89 100644 --- a/api/src/modules/map/canvass/canvass.service.ts +++ b/api/src/modules/map/canvass/canvass.service.ts @@ -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( diff --git a/api/src/modules/map/shifts/shifts.service.ts b/api/src/modules/map/shifts/shifts.service.ts index 2262308d..53fe207e 100644 --- a/api/src/modules/map/shifts/shifts.service.ts +++ b/api/src/modules/map/shifts/shifts.service.ts @@ -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 { diff --git a/api/src/modules/media/routes/public.routes.ts b/api/src/modules/media/routes/public.routes.ts index 91031be5..e40706dc 100644 --- a/api/src/modules/media/routes/public.routes.ts +++ b/api/src/modules/media/routes/public.routes.ts @@ -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 { diff --git a/api/src/modules/media/routes/video-actions.routes.ts b/api/src/modules/media/routes/video-actions.routes.ts index 73e0cd81..1e75bd2f 100644 --- a/api/src/modules/media/routes/video-actions.routes.ts +++ b/api/src/modules/media/routes/video-actions.routes.ts @@ -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; diff --git a/api/src/modules/media/routes/video-streaming.routes.ts b/api/src/modules/media/routes/video-streaming.routes.ts index 44b96d9a..cc2cfd3a 100644 --- a/api/src/modules/media/routes/video-streaming.routes.ts +++ b/api/src/modules/media/routes/video-streaming.routes.ts @@ -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); diff --git a/api/src/modules/media/routes/videos.routes.ts b/api/src/modules/media/routes/videos.routes.ts index 095598df..eeed5b0f 100644 --- a/api/src/modules/media/routes/videos.routes.ts +++ b/api/src/modules/media/routes/videos.routes.ts @@ -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' }); } } ); diff --git a/api/src/modules/pages/pages.service.ts b/api/src/modules/pages/pages.service.ts index ae8a569b..be9eb14f 100644 --- a/api/src/modules/pages/pages.service.ts +++ b/api/src/modules/pages/pages.service.ts @@ -103,10 +103,13 @@ function validateStubPath(stubPath: string): void { function wrapInMaterialOverride(html: string, css: string | null): string { const styleBlock = css ? `` : ''; + // 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 %} `; } diff --git a/api/src/modules/payments/donations.service.ts b/api/src/modules/payments/donations.service.ts index fd1095f6..b269d417 100644 --- a/api/src/modules/payments/donations.service.ts +++ b/api/src/modules/payments/donations.service.ts @@ -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 || '', diff --git a/api/src/modules/payments/payments-admin.routes.ts b/api/src/modules/payments/payments-admin.routes.ts index d263b033..0f32f24d 100644 --- a/api/src/modules/payments/payments-admin.routes.ts +++ b/api/src/modules/payments/payments-admin.routes.ts @@ -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(',')); } diff --git a/api/src/modules/payments/subscriptions.service.ts b/api/src/modules/payments/subscriptions.service.ts index 515dbd14..d83af969 100644 --- a/api/src/modules/payments/subscriptions.service.ts +++ b/api/src/modules/payments/subscriptions.service.ts @@ -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(), diff --git a/api/src/modules/social/social.routes.ts b/api/src/modules/social/social.routes.ts index b8808966..b5401e9b 100644 --- a/api/src/modules/social/social.routes.ts +++ b/api/src/modules/social/social.routes.ts @@ -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(); diff --git a/api/src/modules/ticketed-events/checkin.routes.ts b/api/src/modules/ticketed-events/checkin.routes.ts index 44b21acf..beb8cf54 100644 --- a/api/src/modules/ticketed-events/checkin.routes.ts +++ b/api/src/modules/ticketed-events/checkin.routes.ts @@ -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) || {}; + 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) => { diff --git a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts index 1f878e0d..21f34e8f 100644 --- a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts +++ b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts @@ -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([ diff --git a/api/src/modules/ticketed-events/ticketed-events-public.routes.ts b/api/src/modules/ticketed-events/ticketed-events-public.routes.ts index f015fcde..442babec 100644 --- a/api/src/modules/ticketed-events/ticketed-events-public.routes.ts +++ b/api/src/modules/ticketed-events/ticketed-events-public.routes.ts @@ -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); } diff --git a/api/src/modules/ticketed-events/ticketed-events.service.ts b/api/src/modules/ticketed-events/ticketed-events.service.ts index f706f0bb..fd0434ae 100644 --- a/api/src/modules/ticketed-events/ticketed-events.service.ts +++ b/api/src/modules/ticketed-events/ticketed-events.service.ts @@ -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) { + async updateTier(tierId: string, data: Record, 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'); } diff --git a/api/src/modules/ticketed-events/tickets.service.ts b/api/src/modules/ticketed-events/tickets.service.ts index 7517f1cd..41f14ad3 100644 --- a/api/src/modules/ticketed-events/tickets.service.ts +++ b/api/src/modules/ticketed-events/tickets.service.ts @@ -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" */ diff --git a/api/src/modules/users/users.routes.ts b/api/src/modules/users/users.routes.ts index 358a5e5f..8aa9bee5 100644 --- a/api/src/modules/users/users.routes.ts +++ b/api/src/modules/users/users.routes.ts @@ -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); diff --git a/api/src/server.ts b/api/src/server.ts index 3da0d2dc..cb318265 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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) { diff --git a/api/src/services/listmonk.client.ts b/api/src/services/listmonk.client.ts index 74b7a558..4c6ed38d 100644 --- a/api/src/services/listmonk.client.ts +++ b/api/src/services/listmonk.client.ts @@ -131,7 +131,9 @@ class ListmonkClient { async findSubscriberByEmail(email: string): Promise { 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', diff --git a/api/src/services/password-reset-token.service.ts b/api/src/services/password-reset-token.service.ts index 514b8ac7..3f767033 100644 --- a/api/src/services/password-reset-token.service.ts +++ b/api/src/services/password-reset-token.service.ts @@ -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 }; diff --git a/configs/prometheus/prometheus.yml b/configs/prometheus/prometheus.yml index b294c037..bb06e2ee 100644 --- a/configs/prometheus/prometheus.yml +++ b/configs/prometheus/prometheus.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 979221f0..9ec3a494 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/nginx/conf.d/api.conf b/nginx/conf.d/api.conf new file mode 100644 index 00000000..1f3399e6 --- /dev/null +++ b/nginx/conf.d/api.conf @@ -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"; + } +} diff --git a/nginx/conf.d/services.conf b/nginx/conf.d/services.conf new file mode 100644 index 00000000..61429c40 --- /dev/null +++ b/nginx/conf.d/services.conf @@ -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; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 4be3a9b7..176a77a3 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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;