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:
+63
-21
@@ -1,19 +1,56 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, media } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
const mediaRouter = new Hono();
|
||||
|
||||
const UPLOAD_DIR = './uploads';
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||
const MAX_FILE_SIZE =
|
||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||
|
||||
/**
|
||||
* Detect a real image type from the file's magic bytes (content sniffing).
|
||||
* Returns the canonical mime + extension, or null if the content is not an
|
||||
* allowed image. We deliberately ignore the client-supplied filename and
|
||||
* Content-Type so an attacker cannot store e.g. an .html/.svg payload.
|
||||
*/
|
||||
function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
|
||||
if (buf.length < 12) return null;
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: '.jpg' };
|
||||
}
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (
|
||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
||||
) {
|
||||
return { mime: 'image/png', ext: '.png' };
|
||||
}
|
||||
// GIF: "GIF87a" / "GIF89a"
|
||||
if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') {
|
||||
return { mime: 'image/gif', ext: '.gif' };
|
||||
}
|
||||
// WEBP: "RIFF"...."WEBP"
|
||||
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
|
||||
return { mime: 'image/webp', ext: '.webp' };
|
||||
}
|
||||
// AVIF / HEIF: "....ftyp" with an avif/heic brand
|
||||
if (buf.toString('ascii', 4, 8) === 'ftyp') {
|
||||
const brand = buf.toString('ascii', 8, 12);
|
||||
if (brand === 'avif' || brand === 'avis') {
|
||||
return { mime: 'image/avif', ext: '.avif' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure upload directory exists
|
||||
async function ensureUploadDir() {
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
@@ -31,28 +68,30 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
return c.json({ error: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
// Validate file size (cheap check before reading the whole buffer)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||
}
|
||||
|
||||
// Read the bytes and validate the *content* (not the client-provided type/name)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const detected = detectImageType(buffer);
|
||||
if (!detected) {
|
||||
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||
}
|
||||
|
||||
await ensureUploadDir();
|
||||
|
||||
// Generate unique filename
|
||||
// Generate unique filename using the *detected* extension (ignore client filename)
|
||||
const id = generateId();
|
||||
const ext = extname(file.name) || '.jpg';
|
||||
const filename = `${id}${ext}`;
|
||||
const filename = `${id}${detected.ext}`;
|
||||
const filepath = join(UPLOAD_DIR, filename);
|
||||
|
||||
// Write file
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await writeFile(filepath, Buffer.from(arrayBuffer));
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
// Get related info from form data
|
||||
const relatedId = body['relatedId'] as string | undefined;
|
||||
@@ -128,17 +167,20 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const relatedType = c.req.query('relatedType');
|
||||
const relatedId = c.req.query('relatedId');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
// Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle.
|
||||
const conditions: any[] = [];
|
||||
if (relatedType) conditions.push(eq((media as any).relatedType, relatedType));
|
||||
if (relatedId) conditions.push(eq((media as any).relatedId, relatedId));
|
||||
|
||||
let query = (db as any).select().from(media);
|
||||
|
||||
if (relatedType) {
|
||||
query = query.where(eq((media as any).relatedType, relatedType));
|
||||
}
|
||||
if (relatedId) {
|
||||
query = query.where(eq((media as any).relatedId, relatedId));
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
||||
}
|
||||
|
||||
const result = await dbAll(query);
|
||||
const result = await dbAll(query.limit(limit).offset(offset));
|
||||
|
||||
return c.json({ media: result });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user