Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile
Deferred findings from the March 27 security audit, plus a bug fix: MongoDB keyfile (bug fix): - Generate replica.key on first boot via entrypoint script - Fixes crash from --auth + --keyFile without an existing keyfile - Applied to docker-compose.yml, docker-compose.prod.yml, CCP template I7 — Ticket overselling prevention (reservation pattern): - Add reservedCount field to TicketTier schema - Atomically increment reservedCount inside transaction on checkout - Release reservation on checkout.session.completed (webhook) - Release reservation on checkout.session.expired (webhook) - Include reservedCount in availability calculations I17 — Move refresh token to httpOnly cookie: - Server sets httpOnly SameSite=Strict cookie on login/register/refresh - Cookie scoped to /api/auth path, secure in production - Refresh/logout endpoints read from cookie (with body fallback for compat) - Frontend no longer stores refreshToken in localStorage - Auth store simplified: removed refreshToken from state + persistence - API interceptor uses withCredentials:true for automatic cookie sending - Updated media-api, media-public-api, QuickJoinPage, volunteer-invite - Renamed getTokens → getAccessToken across all media components - Install cookie-parser middleware L2 — FeatureGate loading state: - Show Skeleton instead of children while settings are loading - Prevents briefly exposing disabled feature pages Bunker Admin
This commit is contained in:
28
api/package-lock.json
generated
28
api/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
@@ -52,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
@@ -1894,6 +1896,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -2533,6 +2544,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
@@ -60,6 +61,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
|
||||
@@ -4830,6 +4830,7 @@ model TicketTier {
|
||||
minDonationCAD Int? @map("min_donation_cad") // In cents
|
||||
maxQuantity Int? @map("max_quantity")
|
||||
soldCount Int @default(0) @map("sold_count")
|
||||
reservedCount Int @default(0) @map("reserved_count") // Pending checkout sessions
|
||||
maxPerOrder Int @default(10) @map("max_per_order")
|
||||
salesStartAt DateTime? @map("sales_start_at")
|
||||
salesEndAt DateTime? @map("sales_end_at")
|
||||
|
||||
@@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
|
||||
/** Set the refresh token as an httpOnly cookie */
|
||||
function setRefreshCookie(res: Response, token: string) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: REFRESH_COOKIE_MAX_AGE,
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the refresh token cookie */
|
||||
function clearRefreshCookie(res: Response) {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
@@ -27,7 +51,10 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.login(req.body.email, req.body.password);
|
||||
res.json(result);
|
||||
// Set refresh token as httpOnly cookie (not in response body)
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -42,7 +69,14 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.register(req.body);
|
||||
res.status(201).json(result);
|
||||
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
|
||||
if ('refreshToken' in result && result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.status(201).json(responseWithoutRefresh);
|
||||
} else {
|
||||
res.status(201).json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -231,30 +265,44 @@ router.post(
|
||||
);
|
||||
|
||||
// POST /api/auth/refresh
|
||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||
router.post(
|
||||
'/refresh',
|
||||
authRateLimit,
|
||||
validate(refreshSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.refreshTokens(req.body.refreshToken);
|
||||
res.json(result);
|
||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
||||
if (!refreshToken) {
|
||||
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
|
||||
return;
|
||||
}
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
// Set new refresh token as httpOnly cookie
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/logout
|
||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||
router.post(
|
||||
'/logout',
|
||||
authRateLimit,
|
||||
validate(refreshSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await authService.logout(req.body.refreshToken);
|
||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
||||
if (refreshToken) {
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
clearRefreshCookie(res);
|
||||
res.json({ message: 'Logged out' });
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,16 +424,23 @@ export const webhookService = {
|
||||
const { ticketsService } = await import('../ticketed-events/tickets.service');
|
||||
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
|
||||
|
||||
const qty = parseInt(quantity, 10);
|
||||
const tickets = await ticketsService.createTickets({
|
||||
eventId,
|
||||
tierId,
|
||||
quantity: parseInt(quantity, 10),
|
||||
quantity: qty,
|
||||
holderEmail: buyerEmail || order.buyerEmail,
|
||||
holderName: buyerName || order.buyerName || undefined,
|
||||
userId: userId || order.userId || undefined,
|
||||
orderId: order.id,
|
||||
});
|
||||
|
||||
// Release reservation (soldCount was incremented by createTickets)
|
||||
await prisma.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { decrement: qty } },
|
||||
});
|
||||
|
||||
// Fetch event + tier for email
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
@@ -534,6 +541,21 @@ export const webhookService = {
|
||||
where: { id: order.id },
|
||||
data: { status: 'FAILED' },
|
||||
});
|
||||
|
||||
// Release ticket reservations if this was an event ticket checkout
|
||||
const { tierId, quantity } = session.metadata || {};
|
||||
if (tierId && quantity) {
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (qty > 0) {
|
||||
await prisma.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { decrement: qty } },
|
||||
}).catch((err) => {
|
||||
logger.warn(`Failed to release reservation for tier ${tierId}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Checkout expired, order marked failed: ${order.id}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -119,14 +119,6 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
|
||||
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
// Check sales window
|
||||
const now = new Date();
|
||||
if (tier.salesStartAt && tier.salesStartAt > now) {
|
||||
@@ -136,9 +128,33 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
|
||||
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
|
||||
}
|
||||
|
||||
const unitAmount = tier.tierType === 'DONATION'
|
||||
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
|
||||
: tier.priceCAD;
|
||||
// Atomically reserve capacity (soldCount + reservedCount checked together)
|
||||
// This prevents overselling from concurrent Stripe checkout sessions
|
||||
const reserved = await prisma.$transaction(async (tx) => {
|
||||
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
const effectiveSold = currentTier.soldCount + currentTier.reservedCount;
|
||||
if (currentTier.maxQuantity && effectiveSold + quantity > currentTier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
const currentEvent = await tx.ticketedEvent.findUnique({ where: { id: event.id } });
|
||||
if (currentEvent?.maxAttendees && currentEvent.currentAttendees + currentTier.reservedCount + quantity > currentEvent.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
await tx.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { increment: quantity } },
|
||||
});
|
||||
|
||||
return currentTier;
|
||||
});
|
||||
|
||||
const unitAmount = reserved.tierType === 'DONATION'
|
||||
? Math.max(reserved.priceCAD, reserved.minDonationCAD || 0)
|
||||
: reserved.priceCAD;
|
||||
|
||||
const stripe = await getStripe();
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
|
||||
@@ -696,7 +696,7 @@ export const ticketedEventsService = {
|
||||
|
||||
const now = new Date();
|
||||
const tiers = event.ticketTiers.map(t => {
|
||||
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
|
||||
const available = t.maxQuantity ? t.maxQuantity - t.soldCount - (t.reservedCount || 0) : null;
|
||||
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
|
||||
(!t.salesEndAt || t.salesEndAt >= now);
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
|
||||
import { quickJoinRateLimit } from '../../middleware/rate-limit';
|
||||
import { volunteerInviteService } from './volunteer-invite.service';
|
||||
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -33,9 +34,16 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await volunteerInviteService.redeemInvite(req.body);
|
||||
// Set refresh token as httpOnly cookie
|
||||
res.cookie('cml_refresh', result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: '/api/auth',
|
||||
});
|
||||
res.json({
|
||||
accessToken: result.tokens.accessToken,
|
||||
refreshToken: result.tokens.refreshToken,
|
||||
cutId: result.cutId,
|
||||
shiftId: result.shiftId,
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||
import { correlationId } from './middleware/correlation-id';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -133,6 +134,7 @@ app.set('trust proxy', 1);
|
||||
|
||||
// --- Middleware Stack ---
|
||||
app.use(correlationId);
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: env.CSP_ENABLED === 'true'
|
||||
|
||||
Reference in New Issue
Block a user