Implement OWASP ASVS L1 security remediation (Phases 0-2)

Auth: Add requireAuth/requireAdmin guards with JWT cookie verification,
member status checks (suspended/cancelled = 403), and admin role
enforcement. Apply to all admin, upload, and payment endpoints. Add
role field to Member model.

CSRF: Double-submit cookie middleware with client plugin. Exempt
webhook and magic-link verify routes.

Headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
Referrer-Policy, Permissions-Policy on all responses. HSTS and CSP
(Helcim/Cloudinary/Plausible sources) in production only.

Rate limiting: Auth 5/5min, payment 10/min, upload 10/min, general
100/min via rate-limiter-flexible, keyed by client IP.

XSS: DOMPurify sanitization on marked() output with tag/attr
allowlists. escapeHtml() utility for email template interpolation.

Anti-enumeration: Login returns identical response for existing and
non-existing emails. Remove 404 handling from login UI components.

Mass assignment: Remove helcimCustomerId from profile allowedFields.

Session: 7-day token expiry, refresh endpoint, httpOnly+secure cookies.

Environment: Validate required secrets on startup via server plugin.
Remove JWT_SECRET hardcoded fallback.
This commit is contained in:
Jennie Robinson Faber 2026-03-01 12:53:18 +00:00
parent 29c96a207e
commit 26c300c357
41 changed files with 566 additions and 380 deletions

65
server/utils/auth.js Normal file
View file

@ -0,0 +1,65 @@
import jwt from 'jsonwebtoken'
import Member from '../models/member.js'
import { connectDB } from './mongoose.js'
/**
* Verify JWT from cookie and return the decoded member.
* Throws 401 if token is missing or invalid.
*/
export async function requireAuth(event) {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
return member
}
/**
* Verify JWT and require admin role.
* Throws 401 if not authenticated, 403 if not admin.
*/
export async function requireAdmin(event) {
const member = await requireAuth(event)
if (member.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
return member
}

View file

@ -0,0 +1,18 @@
const ESCAPE_MAP = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
const ESCAPE_RE = /[&<>"']/g
/**
* Escape HTML special characters to prevent XSS in email templates.
* Returns empty string for null/undefined input.
*/
export function escapeHtml(str) {
if (str == null) return ''
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch])
}

View file

@ -1,4 +1,5 @@
import { Resend } from "resend";
import { escapeHtml } from "./escapeHtml.js";
const resend = new Resend(process.env.RESEND_API_KEY);
@ -33,7 +34,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [registration.email],
subject: `You're registered for ${eventData.title}`,
subject: `You're registered for ${escapeHtml(eventData.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Hi ${escapeHtml(registration.name)},</p>
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p>
<p>Thank you for registering for <strong>${escapeHtml(eventData.title)}</strong>!</p>
<div class="event-details">
<div class="detail-row">
@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
<div class="value">${escapeHtml(eventData.location)}</div>
</div>
</div>
${eventData.description ? `<p>${eventData.description}</p>` : ""}
${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
${
registration.ticketType &&
@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${escapeHtml(registration.paymentId)}</div>
</div>
`
: ""
@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [registration.email],
subject: `Registration cancelled: ${eventData.title}`,
subject: `Registration cancelled: ${escapeHtml(eventData.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Hi ${escapeHtml(registration.name)},</p>
<p>Your registration for <strong>${eventData.title}</strong> has been cancelled.</p>
<p>Your registration for <strong>${escapeHtml(eventData.title)}</strong> has been cancelled.</p>
<p>We're sorry you can't make it. You can always register again if your plans change.</p>
@ -332,7 +333,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [waitlistEntry.email],
subject: `A spot opened up for ${eventData.title}!`,
subject: `A spot opened up for ${escapeHtml(eventData.title)}!`,
html: `
<!DOCTYPE html>
<html>
@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
</div>
<div class="content">
<p>Hi ${waitlistEntry.name},</p>
<p>Hi ${escapeHtml(waitlistEntry.name)},</p>
<p>Great news! A spot has become available for <strong>${eventData.title}</strong>, and you're on the waitlist.</p>
<p>Great news! A spot has become available for <strong>${escapeHtml(eventData.title)}</strong>, and you're on the waitlist.</p>
<div class="urgent">
<p style="margin: 0; font-weight: 600; color: #92400e;">
@ -426,7 +427,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="event-details">
<div class="detail-row">
<div class="label">Event</div>
<div class="value">${eventData.title}</div>
<div class="value">${escapeHtml(eventData.title)}</div>
</div>
<div class="detail-row">
@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
<div class="value">${escapeHtml(eventData.location)}</div>
</div>
</div>
@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [to],
subject: `Your Series Pass for ${series.title}`,
subject: `Your Series Pass for ${escapeHtml(series.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
</div>
<div class="content">
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p>
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${escapeHtml(name)},</p>
<p>
Great news! Your series pass for <strong>${series.title}</strong> is confirmed.
Great news! Your series pass for <strong>${escapeHtml(series.title)}</strong> is confirmed.
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
</p>
@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
<div class="detail-row">
<div class="label">Series</div>
<div class="value">${series.title}</div>
<div class="value">${escapeHtml(series.title)}</div>
</div>
${
@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) {
? `
<div class="detail-row">
<div class="label">About</div>
<div class="value">${series.description}</div>
<div class="value">${escapeHtml(series.description)}</div>
</div>
`
: ""
@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) {
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${escapeHtml(paymentId)}</div>
</div>
`
: ""
@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) {
(event, index) => `
<div class="event-item">
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
Event ${index + 1}: ${event.title}
Event ${index + 1}: ${escapeHtml(event.title)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📅 ${formatDate(event.startDate)}
@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) {
🕐 ${formatTime(event.startDate, event.endDate)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📍 ${event.location}
📍 ${escapeHtml(event.location)}
</div>
</div>
`,