Harden auth, payments, and frontend against review findings.
Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,9 +6,14 @@ import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { emailService } from '../lib/email.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
const contactsRouter = new Hono();
|
||||
|
||||
// Per-IP rate limit for public, unauthenticated write endpoints (contact form,
|
||||
// newsletter subscribe/unsubscribe) to prevent spam and email flooding.
|
||||
const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' });
|
||||
|
||||
// ==================== Sanitization Helpers ====================
|
||||
|
||||
/**
|
||||
@@ -33,14 +38,14 @@ function sanitizeHeaderValue(str: string): string {
|
||||
}
|
||||
|
||||
const createContactSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(10),
|
||||
name: z.string().min(2).max(200),
|
||||
email: z.string().email().max(254),
|
||||
message: z.string().min(10).max(5000),
|
||||
});
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
const updateContactSchema = z.object({
|
||||
@@ -48,7 +53,7 @@ const updateContactSchema = z.object({
|
||||
});
|
||||
|
||||
// Submit contact form (public)
|
||||
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
@@ -125,7 +130,7 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
});
|
||||
|
||||
// Subscribe to newsletter (public)
|
||||
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||||
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Check if already subscribed
|
||||
@@ -166,28 +171,30 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
|
||||
});
|
||||
|
||||
// Unsubscribe from newsletter (public)
|
||||
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||||
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Email not found' }, 404);
|
||||
// Always return the same response whether or not the address exists, to avoid
|
||||
// leaking which emails are subscribed (enumeration).
|
||||
if (existing && existing.status !== 'unsubscribed') {
|
||||
await (db as any)
|
||||
.update(emailSubscribers)
|
||||
.set({ status: 'unsubscribed' })
|
||||
.where(eq((emailSubscribers as any).id, existing.id));
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(emailSubscribers)
|
||||
.set({ status: 'unsubscribed' })
|
||||
.where(eq((emailSubscribers as any).id, existing.id));
|
||||
|
||||
return c.json({ message: 'Successfully unsubscribed' });
|
||||
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
|
||||
});
|
||||
|
||||
// Get all contacts (admin)
|
||||
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
let query = (db as any).select().from(contacts);
|
||||
|
||||
@@ -195,7 +202,9 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(eq((contacts as any).status, status));
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
|
||||
);
|
||||
|
||||
return c.json({ contacts: result });
|
||||
});
|
||||
@@ -255,6 +264,8 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// Get all subscribers (admin)
|
||||
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
let query = (db as any).select().from(emailSubscribers);
|
||||
|
||||
@@ -262,7 +273,9 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
|
||||
query = query.where(eq((emailSubscribers as any).status, status));
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
|
||||
);
|
||||
|
||||
return c.json({ subscribers: result });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user