From 3eaaf1cce6b38740a358463b0508c9ae8658301c Mon Sep 17 00:00:00 2001 From: Francois Ribemont Date: Mon, 9 Feb 2026 03:47:53 +0000 Subject: [PATCH] Adds some rules to exclude invalid images --- components/GameEditor/Metadata.vue | 18 +++++++++--- components/Modal/UploadFile.vue | 7 ++++- components/NewsArticleCreateButton.vue | 8 +++-- pnpm-lock.yaml | 10 +++---- .../api/v1/admin/company/[id]/banner.post.ts | 21 +++++++++++--- server/api/v1/admin/company/[id]/icon.post.ts | 15 ++++++++-- .../api/v1/admin/game/[id]/metadata.post.ts | 8 +++-- server/api/v1/admin/game/image/index.post.ts | 6 ++-- server/api/v1/admin/news/index.post.ts | 16 +++++----- server/api/v1/admin/settings/logo.post.ts | 10 ++++++- server/internal/mimetypes.ts | 29 +++++++++++++++++++ 11 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 server/internal/mimetypes.ts diff --git a/components/GameEditor/Metadata.vue b/components/GameEditor/Metadata.vue index d4bb1657..7354f9ff 100644 --- a/components/GameEditor/Metadata.vue +++ b/components/GameEditor/Metadata.vue @@ -474,6 +474,8 @@ import { import type { SerializeObject } from "nitropack"; import type { H3Error } from "h3"; import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get"; +import { FetchError } from "ofetch"; +import { isImageMimeType } from "~/server/internal/mimetypes"; const showUploadModal = ref(false); const showAddCarouselModal = ref(false); @@ -548,7 +550,7 @@ const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId)); const coreMetadataIconFileUpload = ref(); const coreMetadataLoading = ref(false); -function coreMetadataUploadFiles(e: InputEvent) { +async function coreMetadataUploadFiles(e: InputEvent) { if (coreMetadataIconUrl.value.startsWith("blob")) { URL.revokeObjectURL(coreMetadataIconUrl.value); } @@ -568,8 +570,10 @@ function coreMetadataUploadFiles(e: InputEvent) { ); return; } - const objectUrl = URL.createObjectURL(file); - coreMetadataIconUrl.value = objectUrl; + if (isImageMimeType(await file.arrayBuffer())) { + const objectUrl = URL.createObjectURL(file); + coreMetadataIconUrl.value = objectUrl; + } } async function coreMetadataUpdate() { const formData = new FormData(); @@ -596,12 +600,18 @@ function coreMetadataUpdate_wrapper() { coreMetadataLoading.value = true; coreMetadataUpdate() .catch((e) => { + let errorMessage = ""; + if (e instanceof FetchError) { + errorMessage = e.data.message; + } else { + errorMessage = e as string; + } createModal( ModalType.Notification, { title: t("errors.game.metadata.title"), description: t("errors.game.metadata.description", [ - (e as H3Error)?.statusMessage ?? t("errors.unknown"), + errorMessage ?? t("errors.unknown"), ]), buttonText: t("common.close"), }, diff --git a/components/Modal/UploadFile.vue b/components/Modal/UploadFile.vue index 1a4d95c8..96a306fe 100644 --- a/components/Modal/UploadFile.vue +++ b/components/Modal/UploadFile.vue @@ -124,6 +124,7 @@ import { } from "@headlessui/vue"; import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid"; import { XCircleIcon } from "@heroicons/vue/24/solid"; +import { FetchError } from "ofetch"; const open = defineModel({ required: true, @@ -177,7 +178,11 @@ function uploadFile_wrapper() { uploadLoading.value = true; uploadFile() .catch((error) => { - uploadError.value = error.statusMessage ?? t("errors.unknown"); + if (error instanceof FetchError) { + uploadError.value = error.data.message ?? t("errors.unknown"); + } else { + error.value = error as string; + } }) .finally(() => { uploadLoading.value = false; diff --git a/components/NewsArticleCreateButton.vue b/components/NewsArticleCreateButton.vue index 14a0a49e..4d5f5972 100644 --- a/components/NewsArticleCreateButton.vue +++ b/components/NewsArticleCreateButton.vue @@ -217,6 +217,7 @@ import { XMarkIcon, } from "@heroicons/vue/24/solid"; import { micromark } from "micromark"; +import { FetchError } from "ofetch"; const news = useNews(); if (!news.value) { @@ -414,8 +415,11 @@ async function createArticle() { modalOpen.value = false; } catch (e) { - // @ts-expect-error attempt to get statusMessage on error - error.value = e?.statusMessage ?? t("errors.unknown"); + if (e instanceof FetchError) { + error.value = e.data.message ?? t("errors.unknown"); + } else { + error.value = e as string; + } } finally { loading.value = false; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94441d24..1ef8cb29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4901,9 +4901,9 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} - engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} @@ -12076,7 +12076,7 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.1: + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -13454,7 +13454,7 @@ snapshots: etag: 1.8.1 fresh: 2.0.0 http-errors: 2.0.0 - mime-types: 3.0.1 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 diff --git a/server/api/v1/admin/company/[id]/banner.post.ts b/server/api/v1/admin/company/[id]/banner.post.ts index 02920e6e..a06c06f6 100644 --- a/server/api/v1/admin/company/[id]/banner.post.ts +++ b/server/api/v1/admin/company/[id]/banner.post.ts @@ -1,5 +1,6 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; import objectHandler from "~/server/internal/objects"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; @@ -15,13 +16,25 @@ export default defineEventHandler(async (h3) => { }); if (!company) - throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + throw createError({ statusCode: 400, message: "Invalid company id" }); + + const formData = await readMultipartFormData(h3); + if (!formData) { + throw createError({ statusCode: 400, message: "No file detected" }); + } + const buffer = new Uint8Array(formData[0].data).buffer; + if (!isImageMimeType(buffer)) { + throw createError({ + statusCode: 400, + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, + }); + } const result = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!result) throw createError({ statusCode: 400, - statusMessage: "File upload required (multipart form)", + message: "File upload required (multipart form)", }); const [ids, , pull, dump] = result; @@ -29,7 +42,7 @@ export default defineEventHandler(async (h3) => { if (!id) throw createError({ statusCode: 400, - statusMessage: "Upload at least one file.", + message: "Upload at least one file.", }); await objectHandler.deleteAsSystem(company.mBannerObjectId); @@ -42,7 +55,7 @@ export default defineEventHandler(async (h3) => { }, }); if (count == 0) { - await dump(); + dump(); throw createError({ statusCode: 404, message: "Company not found" }); } await pull(); diff --git a/server/api/v1/admin/company/[id]/icon.post.ts b/server/api/v1/admin/company/[id]/icon.post.ts index 0e6c309f..2da7cf25 100644 --- a/server/api/v1/admin/company/[id]/icon.post.ts +++ b/server/api/v1/admin/company/[id]/icon.post.ts @@ -1,5 +1,6 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; import objectHandler from "~/server/internal/objects"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; @@ -15,13 +16,21 @@ export default defineEventHandler(async (h3) => { }); if (!company) - throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + throw createError({ statusCode: 400, message: "Invalid company id" }); + + const formData = await readMultipartFormData(h3); + if (!formData || !isImageMimeType(new Uint8Array(formData[0].data).buffer)) { + throw createError({ + statusCode: 400, + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, + }); + } const result = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!result) throw createError({ statusCode: 400, - statusMessage: "File upload required (multipart form)", + message: "File upload required (multipart form)", }); const [ids, , pull, dump] = result; @@ -29,7 +38,7 @@ export default defineEventHandler(async (h3) => { if (!id) throw createError({ statusCode: 400, - statusMessage: "Upload at least one file.", + message: "Upload at least one file.", }); await objectHandler.deleteAsSystem(company.mLogoObjectId); diff --git a/server/api/v1/admin/game/[id]/metadata.post.ts b/server/api/v1/admin/game/[id]/metadata.post.ts index 3d46b2e2..faa99c0e 100644 --- a/server/api/v1/admin/game/[id]/metadata.post.ts +++ b/server/api/v1/admin/game/[id]/metadata.post.ts @@ -2,17 +2,19 @@ import type { Prisma } from "~/prisma/client/client"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:update"]); if (!allowed) throw createError({ statusCode: 403 }); const form = await readMultipartFormData(h3); - if (!form) + if (!form || !isImageMimeType(new Uint8Array(form[0].data).buffer)) { throw createError({ statusCode: 400, - statusMessage: "This endpoint requires multipart form data.", + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, }); + } const gameId = getRouterParam(h3, "id")!; @@ -20,7 +22,7 @@ export default defineEventHandler(async (h3) => { if (!uploadResult) throw createError({ statusCode: 400, - statusMessage: "Failed to upload file", + message: "Failed to upload file", }); const [ids, options, pull, dump] = uploadResult; diff --git a/server/api/v1/admin/game/image/index.post.ts b/server/api/v1/admin/game/image/index.post.ts index 4f176137..2a69665f 100644 --- a/server/api/v1/admin/game/image/index.post.ts +++ b/server/api/v1/admin/game/image/index.post.ts @@ -1,17 +1,19 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:new"]); if (!allowed) throw createError({ statusCode: 403 }); const form = await readMultipartFormData(h3); - if (!form) + if (!form || !isImageMimeType(new Uint8Array(form[0].data).buffer)) { throw createError({ statusCode: 400, - statusMessage: "This endpoint requires multipart form data.", + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, }); + } const uploadResult = await handleFileUpload(h3, {}, ["internal:read"]); if (!uploadResult) diff --git a/server/api/v1/admin/news/index.post.ts b/server/api/v1/admin/news/index.post.ts index fb09c2e4..9cf49556 100644 --- a/server/api/v1/admin/news/index.post.ts +++ b/server/api/v1/admin/news/index.post.ts @@ -1,6 +1,7 @@ import { ArkErrors, type } from "arktype"; import { defineEventHandler, createError } from "h3"; import aclManager from "~/server/internal/acls"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; import newsManager from "~/server/internal/news"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; @@ -16,30 +17,31 @@ export default defineEventHandler(async (h3) => { if (!allowed) throw createError({ statusCode: 403 }); const form = await readMultipartFormData(h3); - if (!form) + + if (!form || !isImageMimeType(new Uint8Array(form[0].data).buffer)) { throw createError({ statusCode: 400, - statusMessage: "This endpoint requires multipart form data.", + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, }); - + } const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!uploadResult) throw createError({ statusCode: 400, - statusMessage: "Failed to upload file", + message: "Failed to upload file", }); const [imageIds, options, pull, _dump] = uploadResult; - const body = await CreateNews(options); + const body = CreateNews(options); if (body instanceof ArkErrors) - throw createError({ statusCode: 400, statusMessage: body.summary }); + throw createError({ statusCode: 400, message: body.summary }); const parsedTags = JSON.parse(body.tags); if (typeof parsedTags !== "object" || !Array.isArray(parsedTags)) throw createError({ statusCode: 400, - statusMessage: "Tags must be an array", + message: "Tags must be an array", }); const imageId = imageIds.at(0); diff --git a/server/api/v1/admin/settings/logo.post.ts b/server/api/v1/admin/settings/logo.post.ts index 11f50616..13d26126 100644 --- a/server/api/v1/admin/settings/logo.post.ts +++ b/server/api/v1/admin/settings/logo.post.ts @@ -1,10 +1,18 @@ import aclManager from "~/server/internal/acls"; +import { IMAGE_EXTENSIONS, isImageMimeType } from "~/server/internal/mimetypes"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["settings:update"]); if (!allowed) throw createError({ statusCode: 403 }); + const form = await readMultipartFormData(h3); + if (!form || !isImageMimeType(new Uint8Array(form[0].data).buffer)) { + throw createError({ + statusCode: 400, + message: `File is not an image. Supported file formats: ${IMAGE_EXTENSIONS.join(", ")}`, + }); + } const result = await handleFileUpload(h3, {}, ["anonymous:read"], 1); if (!result) throw createError({ @@ -17,7 +25,7 @@ export default defineEventHandler(async (h3) => { if (!id) throw createError({ statusCode: 400, - statusMessage: "Upload at least one file.", + message: "Upload at least one file.", }); await pull(); diff --git a/server/internal/mimetypes.ts b/server/internal/mimetypes.ts new file mode 100644 index 00000000..e7edbf1a --- /dev/null +++ b/server/internal/mimetypes.ts @@ -0,0 +1,29 @@ +import { parse } from "file-type-mime"; + +export const IMAGE_EXTENSIONS = [ + "bmp", + "gif", + "ico", + "jpeg", + "heic", + "png", + "tiff", +]; + +export const IMAGE_MIME_TYPES = [ + "image/bmp", + "image/gif", + "image/x-icon", + "image/jpeg", + "image/heic", + "image/png", + "image/tiff", +]; + +export function isImageMimeType(file: ArrayBuffer) { + const fileType = parse(new Uint8Array(file).buffer); + if (!fileType || !IMAGE_MIME_TYPES.indexOf(fileType.mime)) { + return false; + } + return true; +}