diff --git a/.env.example b/.env.example index 59aabe42..a5a5e92e 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ V2_POSTGRES_PORT=5433 # --- JWT Auth --- JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32 +JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=7d diff --git a/api/src/config/env.ts b/api/src/config/env.ts index d8df2633..f556ec35 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -25,6 +25,7 @@ const envSchema = z.object({ // JWT JWT_ACCESS_SECRET: z.string().min(32), JWT_REFRESH_SECRET: z.string().min(32), + JWT_INVITE_SECRET: z.string().min(32), JWT_ACCESS_EXPIRY: z.string().default('15m'), JWT_REFRESH_EXPIRY: z.string().default('7d'), diff --git a/api/src/middleware/auth.middleware.ts b/api/src/middleware/auth.middleware.ts index e0f83a9b..2c6ad036 100644 --- a/api/src/middleware/auth.middleware.ts +++ b/api/src/middleware/auth.middleware.ts @@ -20,7 +20,7 @@ export function authenticate(req: Request, _res: Response, next: NextFunction) { const token = header.slice(7); try { - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; req.user = { id: payload.id, email: payload.email, @@ -43,7 +43,7 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction) { const token = header.slice(7); try { - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; req.user = { id: payload.id, email: payload.email, diff --git a/api/src/modules/auth/auth.service.ts b/api/src/modules/auth/auth.service.ts index 3b318103..065db0aa 100644 --- a/api/src/modules/auth/auth.service.ts +++ b/api/src/modules/auth/auth.service.ts @@ -201,7 +201,7 @@ export const authService = { async refreshTokens(refreshToken: string) { let payload: TokenPayload; try { - payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload; + payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload; } catch { throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN'); } @@ -245,6 +245,7 @@ export const authService = { roles: userRoles, }; const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, { + algorithm: 'HS256', expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'], }); @@ -280,6 +281,7 @@ export const authService = { roles: userRoles, }; return jwt.sign(payload, env.JWT_ACCESS_SECRET, { + algorithm: 'HS256', expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'], }); }, @@ -293,6 +295,7 @@ export const authService = { roles: userRoles, }; const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, { + algorithm: 'HS256', expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'], }); diff --git a/api/src/modules/docs/docs-collab.service.ts b/api/src/modules/docs/docs-collab.service.ts index 48e1f7f1..081d62aa 100644 --- a/api/src/modules/docs/docs-collab.service.ts +++ b/api/src/modules/docs/docs-collab.service.ts @@ -74,7 +74,7 @@ const docsExtension: Extension = { // Verify JWT let payload: TokenPayload; try { - payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; } catch { throw new Error('Invalid or expired token'); } diff --git a/api/src/modules/email-templates/email-templates-admin.routes.ts b/api/src/modules/email-templates/email-templates-admin.routes.ts index bf00ec77..3f416f7d 100644 --- a/api/src/modules/email-templates/email-templates-admin.routes.ts +++ b/api/src/modules/email-templates/email-templates-admin.routes.ts @@ -19,6 +19,7 @@ import { BROADCAST_ROLES } from '../../utils/roles'; import rateLimit from 'express-rate-limit'; import RedisStore from 'rate-limit-redis'; import { redis } from '../../config/redis'; +import { seedEmailTemplates } from '../../scripts/seed-email-templates'; const router = Router(); @@ -314,26 +315,7 @@ router.post( requireRole(UserRole.SUPER_ADMIN), async (req: Request, res: Response): Promise => { try { - // This is a placeholder - the actual seeding is done via the script - // But we keep this endpoint for manual triggering if needed - const { spawn } = require('child_process'); - const child = spawn('npx', ['tsx', 'src/scripts/seed-email-templates.ts'], { - cwd: '/app', - shell: false, - }); - - let exitCode = 0; - await new Promise((resolve) => { - child.on('close', (code: number) => { - exitCode = code; - resolve(); - }); - }); - - if (exitCode !== 0) { - throw new Error(`Seed script exited with code ${exitCode}`); - } - + await seedEmailTemplates(); logger.info('Email templates seeded via API'); res.json({ success: true, message: 'Templates seeded successfully' }); } catch (error) { diff --git a/api/src/modules/listmonk/listmonk-webhook.routes.ts b/api/src/modules/listmonk/listmonk-webhook.routes.ts index ae2a1d45..c900ea25 100644 --- a/api/src/modules/listmonk/listmonk-webhook.routes.ts +++ b/api/src/modules/listmonk/listmonk-webhook.routes.ts @@ -6,17 +6,17 @@ import { logger } from '../../utils/logger'; const router = Router(); /** - * POST /api/listmonk/webhook?secret=... + * POST /api/listmonk/webhook * * Handles Listmonk webhook events for reverse sync (e.g., unsubscribes). - * Validates a shared secret query parameter. No JWT auth — Listmonk calls this. + * Validates a shared secret via x-webhook-secret header. No JWT auth — Listmonk calls this. */ router.post( '/webhook', async (req: Request, res: Response, next: NextFunction) => { try { - // Accept secret from header (preferred) or query param (legacy fallback) - const secret = (req.headers['x-webhook-secret'] as string) || (req.query.secret as string); + // Accept secret from header only (query param removed — secrets must not appear in logs) + const secret = req.headers['x-webhook-secret'] as string; if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) { res.status(403).json({ error: 'Invalid webhook secret' }); return; diff --git a/api/src/modules/map/locations/locations.service.ts b/api/src/modules/map/locations/locations.service.ts index 846e1745..fef01b2b 100644 --- a/api/src/modules/map/locations/locations.service.ts +++ b/api/src/modules/map/locations/locations.service.ts @@ -1253,6 +1253,14 @@ export const locationsService = { orderBy: { createdAt: 'desc' }, }); + // Sanitize a field value against CSV formula injection. + // Spreadsheet apps treat cells starting with =, +, -, @, \t, \r as formulas. + // Prefixing with an apostrophe causes Excel/Sheets to treat the value as plain text. + function sanitizeCsvField(value: string): string { + if (/^[=+\-@\t\r]/.test(value)) return `'${value}`; + return value; + } + // Flatten: one row per address const rows: Array> = []; for (const loc of locations) { @@ -1262,16 +1270,16 @@ export const locationsService = { if (filters?.hasSign !== undefined && addr.sign !== filters.hasSign) continue; rows.push({ - address: loc.address || '', - unitNumber: addr.unitNumber || '', - firstName: addr.firstName || '', - lastName: addr.lastName || '', - email: addr.email || '', - phone: addr.phone || '', + address: sanitizeCsvField(loc.address || ''), + unitNumber: sanitizeCsvField(addr.unitNumber || ''), + firstName: sanitizeCsvField(addr.firstName || ''), + lastName: sanitizeCsvField(addr.lastName || ''), + email: sanitizeCsvField(addr.email || ''), + phone: sanitizeCsvField(addr.phone || ''), supportLevel: addr.supportLevel || '', sign: addr.sign ? 'Yes' : 'No', signSize: addr.signSize || '', - notes: addr.notes || '', + notes: sanitizeCsvField(addr.notes || ''), latitude: loc.latitude?.toString() || '', longitude: loc.longitude?.toString() || '', geocodeConfidence: loc.geocodeConfidence?.toString() || '', diff --git a/api/src/modules/media/middleware/auth.ts b/api/src/modules/media/middleware/auth.ts index 903a4c8a..fcf4c2c1 100644 --- a/api/src/modules/media/middleware/auth.ts +++ b/api/src/modules/media/middleware/auth.ts @@ -53,7 +53,7 @@ export async function authenticate( // Verify JWT with V2 access secret let payload: TokenPayload; try { - payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; } catch (error) { return reply.status(401).send({ error: 'Invalid or expired token', @@ -154,7 +154,7 @@ export async function optionalAuth( } try { - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; // Verify user exists and is active const user = await prisma.user.findUnique({ diff --git a/api/src/modules/media/routes/chat-notifications.routes.ts b/api/src/modules/media/routes/chat-notifications.routes.ts index 3ea3cb10..cbfdd3b6 100644 --- a/api/src/modules/media/routes/chat-notifications.routes.ts +++ b/api/src/modules/media/routes/chat-notifications.routes.ts @@ -63,7 +63,7 @@ export async function chatNotificationsRoutes(fastify: FastifyInstance) { // Verify JWT let payload: TokenPayload; try { - payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload; + payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload; } catch { return reply.code(401).send({ message: 'Invalid or expired token' }); } diff --git a/api/src/modules/media/routes/photos.routes.ts b/api/src/modules/media/routes/photos.routes.ts index 4cf3afb6..b3921ed7 100644 --- a/api/src/modules/media/routes/photos.routes.ts +++ b/api/src/modules/media/routes/photos.routes.ts @@ -26,7 +26,7 @@ async function isAdminRequest(request: FastifyRequest): Promise { if (!token) return false; - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as { + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as { id: string; role: UserRole; roles?: UserRole[]; diff --git a/api/src/modules/media/routes/video-streaming.routes.ts b/api/src/modules/media/routes/video-streaming.routes.ts index 10b4d1e3..44b96d9a 100644 --- a/api/src/modules/media/routes/video-streaming.routes.ts +++ b/api/src/modules/media/routes/video-streaming.routes.ts @@ -30,7 +30,7 @@ async function isAdminRequest(request: FastifyRequest): Promise { if (!token) return false; // Verify JWT signature - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as { + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as { id: string; role: UserRole; roles?: UserRole[]; diff --git a/api/src/modules/meeting-planner/meeting-planner.service.ts b/api/src/modules/meeting-planner/meeting-planner.service.ts index 78250036..6c17ef97 100644 --- a/api/src/modules/meeting-planner/meeting-planner.service.ts +++ b/api/src/modules/meeting-planner/meeting-planner.service.ts @@ -7,6 +7,7 @@ import { generateSlug } from '../../utils/slug'; import { logger } from '../../utils/logger'; import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service'; import { participantNeedsService } from '../people/participant-needs.service'; +import type { UpsertNeedsInput } from '../people/participant-needs.schemas'; import type { CreatePollInput, UpdatePollInput, @@ -895,7 +896,7 @@ export const meetingPlannerService = { optionIds: string[], userId?: string, voterToken?: string, - participantNeeds?: Record, + participantNeeds?: UpsertNeedsInput, ) { const normalizedEmail = voterEmail.trim().toLowerCase(); diff --git a/api/src/modules/qr/qr.routes.ts b/api/src/modules/qr/qr.routes.ts index b518b292..28f0e08f 100644 --- a/api/src/modules/qr/qr.routes.ts +++ b/api/src/modules/qr/qr.routes.ts @@ -10,6 +10,10 @@ router.get('/', async (req, res, next) => { res.status(400).json({ error: { message: 'text parameter is required' } }); return; } + if (text.length > 2000) { + res.status(400).json({ error: { message: 'text parameter must be 2000 characters or fewer' } }); + return; + } const rawSize = parseInt(req.query.size as string, 10); const size = Math.min(500, Math.max(50, isNaN(rawSize) ? 200 : rawSize)); diff --git a/api/src/modules/volunteer-invite/volunteer-invite.service.ts b/api/src/modules/volunteer-invite/volunteer-invite.service.ts index 10beb3e6..48910975 100644 --- a/api/src/modules/volunteer-invite/volunteer-invite.service.ts +++ b/api/src/modules/volunteer-invite/volunteer-invite.service.ts @@ -28,7 +28,7 @@ export const volunteerInviteService = { ...(shiftId && { shiftId }), }; - return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' }); + return jwt.sign(payload, env.JWT_INVITE_SECRET, { algorithm: 'HS256', expiresIn: '30m' }); }, /** @@ -39,7 +39,7 @@ export const volunteerInviteService = { // 1. Verify and decode the invite token let payload: InviteTokenPayload; try { - const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET); + const decoded = jwt.verify(input.token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] }); payload = decoded as InviteTokenPayload; } catch { throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN'); diff --git a/api/src/scripts/seed-email-templates.ts b/api/src/scripts/seed-email-templates.ts index 9d3f3104..0ebb051b 100755 --- a/api/src/scripts/seed-email-templates.ts +++ b/api/src/scripts/seed-email-templates.ts @@ -10,8 +10,6 @@ import { PrismaClient, EmailTemplateCategory } from '@prisma/client'; import * as fs from 'fs'; import * as path from 'path'; -const prisma = new PrismaClient(); - interface TemplateDefinition { key: string; name: string; @@ -168,6 +166,7 @@ const TEMPLATES: TemplateDefinition[] = [ ]; async function seedEmailTemplates() { + const prisma = new PrismaClient(); console.log('🌱 Starting email template seeding...\n'); // Find or create SUPER_ADMIN user for created_by @@ -281,13 +280,17 @@ async function seedEmailTemplates() { console.log(` Skipped: ${skippedCount}`); console.log(` Errors: ${errorCount}`); console.log('═'.repeat(60)); + + await prisma.$disconnect(); } -seedEmailTemplates() - .catch((error) => { - console.error('Fatal error:', error); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); +export { seedEmailTemplates }; + +// Only auto-execute when run directly (not when imported as a module) +if (require.main === module) { + seedEmailTemplates() + .catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); +} diff --git a/api/src/services/listmonk-proxy.service.ts b/api/src/services/listmonk-proxy.service.ts index 70e4d47d..fb3d4e37 100644 --- a/api/src/services/listmonk-proxy.service.ts +++ b/api/src/services/listmonk-proxy.service.ts @@ -82,7 +82,7 @@ export function startProxy(): http.Server { } try { - const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as { + const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as { id: string; role: UserRole; roles?: UserRole[]; diff --git a/api/src/services/pangolin.client.ts b/api/src/services/pangolin.client.ts index 32eb74f2..c6f805a2 100644 --- a/api/src/services/pangolin.client.ts +++ b/api/src/services/pangolin.client.ts @@ -110,6 +110,7 @@ export interface UpdateResourcePayload { subdomain?: string; fullDomain?: string; ssl?: boolean; + sso?: boolean; active?: boolean; blockAccess?: boolean; proxyPort?: number;