From 091c36cbf338b673110b0806d76df26d52516468 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 11 Jun 2026 10:40:59 -0700 Subject: [PATCH 1/4] create upload middleware --- server/src/api/middleware/handleErrors.ts | 9 ++++++-- server/src/api/middleware/upload.ts | 27 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 server/src/api/middleware/upload.ts diff --git a/server/src/api/middleware/handleErrors.ts b/server/src/api/middleware/handleErrors.ts index ff591484f..2f817b2af 100755 --- a/server/src/api/middleware/handleErrors.ts +++ b/server/src/api/middleware/handleErrors.ts @@ -1,11 +1,16 @@ import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; import { logger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; +// Multer throws MulterError, send 413 Payload Too Large / 400 +const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400); + const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => { - const status = error instanceof AppError ? error.status || 500 : 500; + const status = + error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500; const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; - const service = error instanceof AppError ? error.service : "unknownService"; + const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService"; const method = error instanceof AppError ? error.method : "unknownMethod"; logger.error({ message: message, diff --git a/server/src/api/middleware/upload.ts b/server/src/api/middleware/upload.ts new file mode 100644 index 000000000..c3f257884 --- /dev/null +++ b/server/src/api/middleware/upload.ts @@ -0,0 +1,27 @@ +import multer from "multer"; +import { AppError } from "@/utils/AppError.js"; +import { ImageMimeTypes, MAX_IMAGE_SIZE_BYTES } from "@/types/upload.js"; + +// Reusable multer instance for handling image uploads +const imageUpload = multer({ + limits: { + fileSize: MAX_IMAGE_SIZE_BYTES, + files: 1, + }, + fileFilter: (_req, file, cb) => { + if (!(ImageMimeTypes as readonly string[]).includes(file.mimetype)) { + cb( + new AppError({ + status: 415, + message: "File must be a valid image (jpeg, jpg, or png)", + service: "uploadMiddleware", + method: "fileFilter", + }) + ); + return; + } + cb(null, true); + }, +}); + +export { imageUpload }; From 0ca738b080937a5df1f0f5e96bdfc06cff14a2c1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 11 Jun 2026 10:41:53 -0700 Subject: [PATCH 2/4] add an upload type with size constraints and acceptable file types --- server/src/types/upload.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 server/src/types/upload.ts diff --git a/server/src/types/upload.ts b/server/src/types/upload.ts new file mode 100644 index 000000000..355688839 --- /dev/null +++ b/server/src/types/upload.ts @@ -0,0 +1,5 @@ +// Shared constraints for user supplied image uploads +export const ImageMimeTypes = ["image/jpeg", "image/png", "image/jpg"] as const; +export type ImageMimeType = (typeof ImageMimeTypes)[number]; + +export const MAX_IMAGE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB From a43363ebfcb232b267b7415e2530e8d4356ce45b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 11 Jun 2026 10:42:05 -0700 Subject: [PATCH 3/4] apply middleware --- server/src/api/routes/authRoute.ts | 12 +++++------- server/src/api/routes/statusPageRoute.ts | 7 +++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/server/src/api/routes/authRoute.ts b/server/src/api/routes/authRoute.ts index 22055ae40..e8ed31f2e 100644 --- a/server/src/api/routes/authRoute.ts +++ b/server/src/api/routes/authRoute.ts @@ -1,10 +1,8 @@ import { Router, RequestHandler } from "express"; -import { isAllowed } from "../middleware/isAllowed.js"; -import multer from "multer"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { imageUpload } from "@/api/middleware/upload.js"; import { IAuthController } from "@/api/controllers/authController.js"; -const upload = multer(); - class AuthRoutes { private router: Router; private authController: IAuthController; @@ -16,7 +14,7 @@ class AuthRoutes { } initRoutes(verifyJWT: RequestHandler) { - this.router.post("/register", upload.single("profileImage"), this.authController.registerUser); + this.router.post("/register", imageUpload.single("profileImage"), this.authController.registerUser); this.router.post("/login", this.authController.loginUser); this.router.post("/recovery/request", this.authController.requestRecovery); @@ -26,13 +24,13 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); - this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser); + this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), imageUpload.single("profileImage"), this.authController.createUser); this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById); this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); - this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); + this.router.patch("/user", verifyJWT, imageUpload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser); } diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index 807970e4b..90b759048 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,7 +1,6 @@ import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; -import multer from "multer"; -const upload = multer(); +import { imageUpload } from "@/api/middleware/upload.js"; class StatusPageRoutes { private router: Router; @@ -16,8 +15,8 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); + this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); From ac77216f0717b73a274aa6bbce37f0b33c4194c1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 11 Jun 2026 10:42:15 -0700 Subject: [PATCH 4/4] add middleware tests --- .../test/unit/middleware/handleErrors.test.ts | 38 +++++++++++++++++ server/test/unit/middleware/upload.test.ts | 42 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 server/test/unit/middleware/handleErrors.test.ts create mode 100644 server/test/unit/middleware/upload.test.ts diff --git a/server/test/unit/middleware/handleErrors.test.ts b/server/test/unit/middleware/handleErrors.test.ts new file mode 100644 index 000000000..5994cf7d3 --- /dev/null +++ b/server/test/unit/middleware/handleErrors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; +import { handleErrors } from "../../../src/api/middleware/handleErrors.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response["status"]; + res.json = jest.fn().mockReturnValue(res) as unknown as Response["json"]; + return res; +}; + +describe("handleErrors", () => { + it("maps a multer file-size violation to 413 Payload Too Large", () => { + const res = makeRes(); + handleErrors(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(413); + }); + + it("maps other multer errors to 400", () => { + const res = makeRes(); + handleErrors(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("preserves AppError status codes", () => { + const res = makeRes(); + handleErrors(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(415); + }); + + it("falls back to 500 for unknown errors", () => { + const res = makeRes(); + handleErrors(new Error("boom"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(500); + }); +}); diff --git a/server/test/unit/middleware/upload.test.ts b/server/test/unit/middleware/upload.test.ts new file mode 100644 index 000000000..bbdfb205c --- /dev/null +++ b/server/test/unit/middleware/upload.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@jest/globals"; +import type { Request } from "express"; +import { imageUpload } from "../../../src/api/middleware/upload.ts"; +import { MAX_IMAGE_SIZE_BYTES } from "../../../src/types/upload.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +// Reaches into the multer instance config to exercise the fileFilter/limits in isolation. +// Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads). +const config = (imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } }); + +const makeFile = (mimetype: string): Express.Multer.File => + ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File; + +const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> => + new Promise((resolve) => { + config.fileFilter({} as Request, makeFile(mimetype), (error: unknown, accepted: unknown) => resolve({ error, accepted })); + }); + +describe("imageUpload middleware", () => { + it("enforces a file size limit so large uploads cannot exhaust memory", () => { + expect(config.limits.fileSize).toBe(MAX_IMAGE_SIZE_BYTES); + expect(config.limits.fileSize).toBeLessThanOrEqual(3 * 1024 * 1024); + }); + + it("limits a request to a single file", () => { + expect(config.limits.files).toBe(1); + }); + + it("accepts allowed image mime types", async () => { + for (const mimetype of ["image/jpeg", "image/png", "image/jpg"]) { + const { error, accepted } = await runFilter(mimetype); + expect(error).toBeNull(); + expect(accepted).toBe(true); + } + }); + + it("rejects non-image uploads with a 415 AppError", async () => { + const { error } = await runFilter("application/octet-stream"); + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).status).toBe(415); + }); +});