From a17a099c4655adeed90e314481a6df98d352b12e Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Sun, 5 Jul 2026 02:09:24 +0900 Subject: [PATCH] fix(upload): enforce size/type limits and derive storage path server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/upload uses the service-role Supabase client (bypasses storage RLS), which src/lib/supabaseAdmin.ts's own doc comment says callers must gate with their own authorization/limits before running. This route had none: no file size cap, no content-type allowlist (the client-reported file.type was trusted directly as contentType), and the storage path came straight from the request body instead of being generated server-side. Anonymous submission is intentional here (visitors upload a logo/screenshot while submitting a new listing, see src/views/Submit.tsx), so this doesn't add auth — but it does close off: - unbounded storage/egress cost from arbitrarily large or numerous uploads (now capped at 5MB per file) - uploading non-image content under an attacker-chosen content-type (now allowlisted to png/jpeg/webp/gif/svg) - a caller picking their own storage key, which could collide with another upload's path (upsert: false would then reject the legitimate one) or be used to write outside the flat filename pattern the frontend generates (path is now always server-derived: timestamp + randomUUID + extension) --- app/api/upload/route.ts | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 82b0674..da30436 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -4,11 +4,28 @@ export const dynamic = 'force-dynamic' const ALLOWED_BUCKETS = ['software-logos', 'software-images'] +// This route uses the service-role client (bypasses storage RLS) with no +// session/ownership check, since anonymous visitors legitimately upload a +// logo/screenshot while submitting a new listing (see src/views/Submit.tsx). +// That's fine, but the request body was otherwise fully attacker-controlled: +// no size cap, no content-type allowlist, and the storage key itself came +// straight from the client instead of being derived server-side. Any caller +// hitting this endpoint directly (skipping the frontend) could write +// arbitrary content/size to any key in these buckets, unlike every other +// mutating endpoint in this codebase. +const ALLOWED_TYPES: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/svg+xml': 'svg', +} +const MAX_FILE_BYTES = 5 * 1024 * 1024 // 5MB + export async function POST(req: Request) { try { const form = await req.formData() const bucket = form.get('bucket') - const path = form.get('path') const file = form.get('file') if (typeof bucket !== 'string' || !ALLOWED_BUCKETS.includes(bucket)) { @@ -18,20 +35,33 @@ export async function POST(req: Request) { }) } - if (typeof path !== 'string' || !path) { - return new Response(JSON.stringify({ error: 'path is required' }), { + if (!(file instanceof File)) { + return new Response(JSON.stringify({ error: 'file is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }) } - if (!(file instanceof File)) { - return new Response(JSON.stringify({ error: 'file is required' }), { + const ext = ALLOWED_TYPES[file.type] + if (!ext) { + return new Response(JSON.stringify({ error: 'Unsupported file type' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }) } + if (file.size > MAX_FILE_BYTES) { + return new Response(JSON.stringify({ error: 'File too large (max 5MB)' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Derive the storage key server-side instead of trusting a client-supplied + // path, so a caller can't collide with/squat another upload's key or write + // outside the intended flat filename pattern. + const path = `${Date.now()}-${crypto.randomUUID()}.${ext}` + const { error } = await getSupabaseAdmin() .storage.from(bucket) .upload(path, file, { upsert: false, contentType: file.type })