diff --git a/components/AccountSettings.tsx b/components/AccountSettings.tsx index 8055898..a4ddde7 100644 --- a/components/AccountSettings.tsx +++ b/components/AccountSettings.tsx @@ -4,13 +4,11 @@ import { useI18n } from "@/components/I18nProvider"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { UserRound, LockKeyhole, Mail } from "lucide-react"; -import { createBrowserClient } from "@supabase/ssr"; import { api } from "./StartGame"; import { PasswordForm } from "./PasswordForm"; import { displayNameSchema } from "@/shared/account"; import { defaultGlowColor, - profileAvatarBucket, profileAvatarMaxBytes, profileAvatarMimeTypes, type ProfileAppearance, @@ -68,37 +66,18 @@ export function AccountSettings({ setPhotoError("Choose a JPG, PNG or WebP image."); return; } - const extension = - file.type === "image/jpeg" - ? "jpg" - : file.type === "image/png" - ? "png" - : "webp"; - const path = `${accountId}/${crypto.randomUUID()}.${extension}`; - const client = createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, - ); setPhotoBusy(true); - let uploaded = false; try { - const { error: uploadError } = await client.storage - .from(profileAvatarBucket) - .upload(path, file, { - cacheControl: "31536000", - contentType: file.type, - upsert: false, - }); - if (uploadError) - throw new Error("Could not upload your profile photo. Please try again."); - uploaded = true; - await api("profile", { avatarPath: path }, "PATCH"); - setPhotoPath(path); + const form = new FormData(); + form.append("photo", file); + const result = await api<{ profile: { avatar_path: string } }>( + "profile/avatar", + form, + ); + setPhotoPath(result.profile.avatar_path); setPhotoSaved(true); router.refresh(); } catch (error) { - if (uploaded) - await client.storage.from(profileAvatarBucket).remove([path]); setPhotoError((error as Error).message); } finally { setPhotoBusy(false); diff --git a/lib/account-api.ts b/lib/account-api.ts index 9797a79..3d92be3 100644 --- a/lib/account-api.ts +++ b/lib/account-api.ts @@ -1,5 +1,7 @@ import "server-only"; import { createClient } from "@supabase/supabase-js"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; import { NextRequest, NextResponse } from "next/server"; import { admin, @@ -10,6 +12,8 @@ import { accountState, } from "./supabase"; import { accountProfile } from "./profile"; +import { browserAccount } from "./request-security"; +import { ServiceError } from "./service-error"; import { authActionSchema, authDestination, @@ -24,7 +28,6 @@ import { issueInitialPassword } from "@/shared/initial-password"; import { profileAvatarBucket, profileAvatarMaxBytes, - profileAvatarMimeTypes, } from "@/shared/profile-appearance"; type Context = { @@ -33,6 +36,98 @@ type Context = { }; const json = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } }); +const avatarRequestLimit = profileAvatarMaxBytes + 64 * 1024; + +async function avatarFile(request: NextRequest): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!/^multipart\/form-data\s*;.*\bboundary=/i.test(contentType)) + throw new ServiceError("Choose a valid profile photo", 400); + const contentLength = request.headers.get("content-length"); + if ( + contentLength && + /^\d+$/.test(contentLength) && + Number(contentLength) > avatarRequestLimit + ) + throw new ServiceError("Image must be 5 MB or smaller", 413); + + const reader = request.body?.getReader(); + if (!reader) throw new ServiceError("Choose a profile photo", 400); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > avatarRequestLimit) { + try { + await reader.cancel(); + } catch { + // The request is already being rejected; cancellation is best effort. + } + throw new ServiceError("Image must be 5 MB or smaller", 413); + } + chunks.push(value); + } + + let form: FormData; + try { + const boundedRequest = new Request(request.url, { + method: "POST", + headers: { "content-type": contentType }, + body: Buffer.concat(chunks, size), + }); + form = await boundedRequest.formData(); + } catch { + throw new ServiceError("Choose a valid profile photo", 400); + } + const entries = [...form.entries()]; + if (entries.length !== 1) + throw new ServiceError("Choose a valid profile photo", 400); + const entry = entries[0]!; + if ( + entry[0] !== "photo" || + typeof entry[1] === "string" || + !(entry[1] instanceof File) + ) + throw new ServiceError("Choose a valid profile photo", 400); + const file = entry[1]; + if (!file.size || file.size > profileAvatarMaxBytes) + throw new ServiceError("Image must be 5 MB or smaller", 413); + return file; +} + +async function normalizedAvatar(file: File) { + try { + const image = sharp(Buffer.from(await file.arrayBuffer()), { + limitInputPixels: 24_000_000, + failOn: "error", + animated: false, + }); + const metadata = await image.metadata(); + if ( + !["jpeg", "png", "webp"].includes(metadata.format ?? "") || + (metadata.pages ?? 1) !== 1 + ) + throw new ServiceError("Choose a JPG, PNG or WebP image", 400); + const output = await image + .rotate() + .resize({ + width: 512, + height: 512, + fit: "inside", + withoutEnlargement: true, + }) + .webp({ quality: 85 }) + .toBuffer(); + if (output.byteLength > profileAvatarMaxBytes) + throw new ServiceError("Image must be 5 MB or smaller", 413); + return output; + } catch (error) { + if (error instanceof ServiceError) throw error; + throw new ServiceError("Choose a valid JPG, PNG or WebP image", 400); + } +} + export async function accountApi( request: NextRequest, path: string[], @@ -57,6 +152,52 @@ export async function accountApi( }); return response; } + if (path[0] === "profile" && path[1] === "avatar" && path.length === 2) { + if (request.method !== "POST") + return json({ error: "Method not allowed" }, 405); + const account = await browserAccount(request, user); + await context.limit(`profile:avatar:${account.id}`, 10, 3600); + const file = await avatarFile(request); + const image = await normalizedAvatar(file); + const profileBefore = await accountProfile(account); + const avatarPath = `${account.id}/${randomUUID()}.webp`; + const storage = admin().storage.from(profileAvatarBucket); + const upload = await storage.upload(avatarPath, image, { + cacheControl: "31536000", + contentType: "image/webp", + upsert: false, + }); + if (upload.error) + throw new ServiceError( + "Profile photo upload failed. Please try again.", + 503, + ); + + const profile = await (async () => { + try { + return required( + await admin() + .from("profiles") + .update({ avatar_path: avatarPath }) + .eq("id", account.id) + .select( + "id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon", + ) + .single(), + ); + } catch (error) { + await storage.remove([avatarPath]).catch(() => {}); + throw error; + } + })(); + if ( + profileBefore.avatar_path && + profileBefore.avatar_path.startsWith(`${account.id}/`) && + profileBefore.avatar_path.split("/").length === 2 + ) + await storage.remove([profileBefore.avatar_path]).catch(() => {}); + return json({ profile }); + } if ( path[0] === "auth" && path[1] === "initial-password" && @@ -92,8 +233,7 @@ export async function accountApi( return json({ ok: true }); } if (path[0] === "profile" && path.length === 1) { - const account = await user(); - if (!account) return json({ error: "Log in to manage your account" }, 401); + const account = await browserAccount(request, user); if (request.method === "GET") return json({ profile: await accountProfile(account), @@ -102,46 +242,20 @@ export async function accountApi( if (request.method === "PATCH") { const body = profileUpdateSchema.parse(await context.jsonBody(request)); const current = await accountProfile(account); + if ( + body.avatarPath !== undefined && + body.avatarPath !== null && + body.avatarPath !== current.avatar_path + ) + return json( + { error: "Upload a profile photo through the avatar form" }, + 400, + ); if (body.glowColor !== undefined && !current.name_glow) return json( { error: "Earn a username glow before choosing its color" }, 403, ); - if ( - body.avatarPath !== undefined && - body.avatarPath !== null && - body.avatarPath !== current.avatar_path - ) { - const [ownerId, filename] = body.avatarPath.split("/"); - if ( - ownerId !== account.id || - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/i.test( - filename ?? "", - ) - ) - return json({ error: "Choose a valid profile photo" }, 400); - const uploaded = await admin() - .storage.from(profileAvatarBucket) - .info(body.avatarPath); - if (uploaded.error || !uploaded.data) - return json( - { error: "Upload your profile photo again" }, - 400, - ); - if ( - !profileAvatarMimeTypes.includes( - uploaded.data.contentType as (typeof profileAvatarMimeTypes)[number], - ) || - !uploaded.data.size || - uploaded.data.size > profileAvatarMaxBytes - ) { - await admin().storage.from(profileAvatarBucket).remove([body.avatarPath]); - return json( - { error: "Choose an image no larger than 5 MB" }, - 400, - ); - } - } const profile = required( await admin() .from("profiles") @@ -166,7 +280,10 @@ export async function accountApi( body.avatarPath !== undefined && body.avatarPath !== current.avatar_path ) { - if (current.avatar_path?.startsWith(`${account.id}/`)) + if ( + current.avatar_path?.startsWith(`${account.id}/`) && + current.avatar_path.split("/").length === 2 + ) await admin() .storage.from(profileAvatarBucket) .remove([current.avatar_path]); diff --git a/lib/client-api.ts b/lib/client-api.ts index 93eca17..6e960b8 100644 --- a/lib/client-api.ts +++ b/lib/client-api.ts @@ -4,14 +4,23 @@ export async function api>( method = body === undefined ? "GET" : "POST", ): Promise { let response: Response; + const formData = + typeof FormData !== "undefined" && body instanceof FormData; try { response = await fetch("/api/v1/" + path, { signal: AbortSignal.timeout(30000), cache: "no-store", method, headers: - body === undefined ? undefined : { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), + body === undefined || formData + ? undefined + : { "Content-Type": "application/json" }, + body: + body === undefined + ? undefined + : typeof FormData !== "undefined" && body instanceof FormData + ? body + : JSON.stringify(body), }); } catch (error) { if ( diff --git a/lib/request-origin.ts b/lib/request-origin.ts index 10486d4..891290a 100644 --- a/lib/request-origin.ts +++ b/lib/request-origin.ts @@ -40,7 +40,14 @@ export function requestOrigin( // A different configured host must not redirect a LAN login to localhost. if (websiteUrl) { const configured = parseOrigin(websiteUrl); - if (configured.host === direct.host) return configured.origin; + // In production MINEGEO_URL is required and is the trust anchor. Never let + // an untrusted Host header choose an OAuth callback or pass the CSRF check. + // Development keeps the direct-host fallback for LAN testing. + if ( + configured.host === direct.host || + process.env.NODE_ENV === "production" + ) + return configured.origin; } return direct.origin; } @@ -63,7 +70,9 @@ export function verifyOrigin( if (parsed.pathname !== "/" || parsed.search || parsed.hash) { throw new Error("Invalid request origin"); } - const allowed = new Set([requestOrigin(request)]); + const allowed = new Set([ + requestOrigin(request, configuration.websiteUrl), + ]); for (const value of [ configuration.websiteUrl, ...(configuration.additionalOrigins?.split(",") ?? []), diff --git a/supabase/migrations/20261015085948_avatar_upload_hardening.sql b/supabase/migrations/20261015085948_avatar_upload_hardening.sql new file mode 100644 index 0000000..c864e16 --- /dev/null +++ b/supabase/migrations/20261015085948_avatar_upload_hardening.sql @@ -0,0 +1,13 @@ +-- The application server is the only writer for public profile avatars. It +-- validates and re-encodes uploads, applies an account rate limit, and removes +-- replaced files. Client-side Storage uploads could bypass those controls. +drop policy if exists "MineGeo users upload their own avatars" + on storage.objects; +drop policy if exists "MineGeo users delete their own avatars" + on storage.objects; + +update storage.buckets +set public = true, + file_size_limit = 5242880, + allowed_mime_types = array['image/webp'] +where id = 'minegeo-avatars';