Security hardening: JWT algorithm pinning, key separation, injection fixes

- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign()
  calls (3 sites) — prevents algorithm confusion attacks
- Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a
  dedicated key separate from access/refresh secrets
- Remove req.query.secret fallback from Listmonk webhook route — secrets
  must not appear in nginx access logs
- Replace child_process.spawn in email template seed endpoint with direct
  function import; add require.main guard to seed script
- Add sanitizeCsvField() to location CSV export to prevent formula
  injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix)
- Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads
- Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput
  in meeting-planner service; add sso field to UpdateResourcePayload

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-22 12:35:04 -06:00
parent 15fb9b93aa
commit 647efffdc4
18 changed files with 58 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Record<string, string>> = [];
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() || '',

View File

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

View File

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

View File

@ -26,7 +26,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
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[];

View File

@ -30,7 +30,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
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[];

View File

@ -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<string, any>,
participantNeeds?: UpsertNeedsInput,
) {
const normalizedEmail = voterEmail.trim().toLowerCase();

View File

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

View File

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

View File

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

View File

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

View File

@ -110,6 +110,7 @@ export interface UpdateResourcePayload {
subdomain?: string;
fullDomain?: string;
ssl?: boolean;
sso?: boolean;
active?: boolean;
blockAccess?: boolean;
proxyPort?: number;