Merge commit from fork

Advisory fix 1
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-06-11 10:44:42 -07:00
commit 2f22a52116
7 files changed
+127 -13

No files matched your search

+7 -2
View File
@@ -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,
+27
View File
@@ -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 };
+5 -7
View File
@@ -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);
}
+3 -4
View File
@@ -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);
+5
View File
@@ -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
@@ -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);
});
});
@@ -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);
});
});