Secure profile photo uploads and trust configured production origin
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m53s

This commit is contained in:
2026-10-15 14:00:46 +05:00
parent 07cd0993a0
commit 930e00a736
5 changed files with 198 additions and 71 deletions

View File

@@ -4,13 +4,11 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { UserRound, LockKeyhole, Mail } from "lucide-react"; import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { createBrowserClient } from "@supabase/ssr";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm"; import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account"; import { displayNameSchema } from "@/shared/account";
import { import {
defaultGlowColor, defaultGlowColor,
profileAvatarBucket,
profileAvatarMaxBytes, profileAvatarMaxBytes,
profileAvatarMimeTypes, profileAvatarMimeTypes,
type ProfileAppearance, type ProfileAppearance,
@@ -68,37 +66,18 @@ export function AccountSettings({
setPhotoError("Choose a JPG, PNG or WebP image."); setPhotoError("Choose a JPG, PNG or WebP image.");
return; 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); setPhotoBusy(true);
let uploaded = false;
try { try {
const { error: uploadError } = await client.storage const form = new FormData();
.from(profileAvatarBucket) form.append("photo", file);
.upload(path, file, { const result = await api<{ profile: { avatar_path: string } }>(
cacheControl: "31536000", "profile/avatar",
contentType: file.type, form,
upsert: false, );
}); setPhotoPath(result.profile.avatar_path);
if (uploadError)
throw new Error("Could not upload your profile photo. Please try again.");
uploaded = true;
await api("profile", { avatarPath: path }, "PATCH");
setPhotoPath(path);
setPhotoSaved(true); setPhotoSaved(true);
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
if (uploaded)
await client.storage.from(profileAvatarBucket).remove([path]);
setPhotoError((error as Error).message); setPhotoError((error as Error).message);
} finally { } finally {
setPhotoBusy(false); setPhotoBusy(false);

View File

@@ -1,5 +1,7 @@
import "server-only"; import "server-only";
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
admin, admin,
@@ -10,6 +12,8 @@ import {
accountState, accountState,
} from "./supabase"; } from "./supabase";
import { accountProfile } from "./profile"; import { accountProfile } from "./profile";
import { browserAccount } from "./request-security";
import { ServiceError } from "./service-error";
import { import {
authActionSchema, authActionSchema,
authDestination, authDestination,
@@ -24,7 +28,6 @@ import { issueInitialPassword } from "@/shared/initial-password";
import { import {
profileAvatarBucket, profileAvatarBucket,
profileAvatarMaxBytes, profileAvatarMaxBytes,
profileAvatarMimeTypes,
} from "@/shared/profile-appearance"; } from "@/shared/profile-appearance";
type Context = { type Context = {
@@ -33,6 +36,98 @@ type Context = {
}; };
const json = (body: unknown, status = 200) => const json = (body: unknown, status = 200) =>
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } }); NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
const avatarRequestLimit = profileAvatarMaxBytes + 64 * 1024;
async function avatarFile(request: NextRequest): Promise<File> {
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( export async function accountApi(
request: NextRequest, request: NextRequest,
path: string[], path: string[],
@@ -57,6 +152,52 @@ export async function accountApi(
}); });
return response; 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 ( if (
path[0] === "auth" && path[0] === "auth" &&
path[1] === "initial-password" && path[1] === "initial-password" &&
@@ -92,8 +233,7 @@ export async function accountApi(
return json({ ok: true }); return json({ ok: true });
} }
if (path[0] === "profile" && path.length === 1) { if (path[0] === "profile" && path.length === 1) {
const account = await user(); const account = await browserAccount(request, user);
if (!account) return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") if (request.method === "GET")
return json({ return json({
profile: await accountProfile(account), profile: await accountProfile(account),
@@ -102,46 +242,20 @@ export async function accountApi(
if (request.method === "PATCH") { if (request.method === "PATCH") {
const body = profileUpdateSchema.parse(await context.jsonBody(request)); const body = profileUpdateSchema.parse(await context.jsonBody(request));
const current = await accountProfile(account); 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) if (body.glowColor !== undefined && !current.name_glow)
return json( return json(
{ error: "Earn a username glow before choosing its color" }, { error: "Earn a username glow before choosing its color" },
403, 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( const profile = required(
await admin() await admin()
.from("profiles") .from("profiles")
@@ -166,7 +280,10 @@ export async function accountApi(
body.avatarPath !== undefined && body.avatarPath !== undefined &&
body.avatarPath !== current.avatar_path 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() await admin()
.storage.from(profileAvatarBucket) .storage.from(profileAvatarBucket)
.remove([current.avatar_path]); .remove([current.avatar_path]);

View File

@@ -4,14 +4,23 @@ export async function api<T = Record<string, unknown>>(
method = body === undefined ? "GET" : "POST", method = body === undefined ? "GET" : "POST",
): Promise<T> { ): Promise<T> {
let response: Response; let response: Response;
const formData =
typeof FormData !== "undefined" && body instanceof FormData;
try { try {
response = await fetch("/api/v1/" + path, { response = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
cache: "no-store", cache: "no-store",
method, method,
headers: headers:
body === undefined ? undefined : { "Content-Type": "application/json" }, body === undefined || formData
body: body === undefined ? undefined : JSON.stringify(body), ? undefined
: { "Content-Type": "application/json" },
body:
body === undefined
? undefined
: typeof FormData !== "undefined" && body instanceof FormData
? body
: JSON.stringify(body),
}); });
} catch (error) { } catch (error) {
if ( if (

View File

@@ -40,7 +40,14 @@ export function requestOrigin(
// A different configured host must not redirect a LAN login to localhost. // A different configured host must not redirect a LAN login to localhost.
if (websiteUrl) { if (websiteUrl) {
const configured = parseOrigin(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; return direct.origin;
} }
@@ -63,7 +70,9 @@ export function verifyOrigin(
if (parsed.pathname !== "/" || parsed.search || parsed.hash) { if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
throw new Error("Invalid request origin"); throw new Error("Invalid request origin");
} }
const allowed = new Set([requestOrigin(request)]); const allowed = new Set([
requestOrigin(request, configuration.websiteUrl),
]);
for (const value of [ for (const value of [
configuration.websiteUrl, configuration.websiteUrl,
...(configuration.additionalOrigins?.split(",") ?? []), ...(configuration.additionalOrigins?.split(",") ?? []),

View File

@@ -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';