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:
parent
15fb9b93aa
commit
647efffdc4
@ -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
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() || '',
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -110,6 +110,7 @@ export interface UpdateResourcePayload {
|
||||
subdomain?: string;
|
||||
fullDomain?: string;
|
||||
ssl?: boolean;
|
||||
sso?: boolean;
|
||||
active?: boolean;
|
||||
blockAccess?: boolean;
|
||||
proxyPort?: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user