Secure profile photo uploads and trust configured production origin
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m53s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m53s
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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(",") ?? []),
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user