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