From 3ea302202dc0daa3db577eb7c68374cdc1ddbc3c Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 12 Apr 2026 10:27:01 +0700 Subject: [PATCH] feat: fix advisory --- .../src/api/routes/media.controller.ts | 1 + .../v1/public.integrations.controller.ts | 27 ++++-- .../src/upload/cloudflare.storage.ts | 38 ++++++-- .../src/upload/custom.upload.validation.ts | 59 +++++++++---- .../src/upload/local.storage.ts | 33 +++++-- .../src/upload/r2.uploader.ts | 88 +++++++++++++++---- package.json | 1 + pnpm-lock.yaml | 3 + var/docker/nginx.conf | 13 +++ 9 files changed, 207 insertions(+), 56 deletions(-) diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index 0da66316..2d561ec2 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -129,6 +129,7 @@ export class MediaController { @Post('/upload-simple') @UseInterceptors(FileInterceptor('file')) + @UsePipes(new CustomFileValidationPipe()) async uploadSimple( @GetOrgFromRequest() org: Organization, @UploadedFile('file') file: Express.Multer.File, diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 4914906a..40ec9ff4 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -10,7 +10,9 @@ import { Query, UploadedFile, UseInterceptors, + UsePipes, } from '@nestjs/common'; +import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation'; import { ApiTags } from '@nestjs/swagger'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization } from '@prisma/client'; @@ -32,7 +34,19 @@ import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/n import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto'; import axios from 'axios'; import { Readable } from 'stream'; -import { lookup, extension } from 'mime-types'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const PUBLIC_API_ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); import * as Sentry from '@sentry/nestjs'; import { socialIntegrationList, IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { getValidationSchemas } from '@gitroom/nestjs-libraries/chat/validation.schemas.helper'; @@ -57,6 +71,7 @@ export class PublicIntegrationsController { @Post('/upload') @UseInterceptors(FileInterceptor('file')) + @UsePipes(new CustomFileValidationPipe()) async uploadSimple( @GetOrgFromRequest() org: Organization, @UploadedFile('file') file: Express.Multer.File @@ -85,10 +100,12 @@ export class PublicIntegrationsController { }); const buffer = Buffer.from(response.data); - const responseMime = response.headers?.['content-type']?.split(';')[0]?.trim(); - const urlMime = lookup(body?.url?.split?.('?')?.[0]); - const mimetype = (urlMime || responseMime || 'image/jpeg') as string; - const ext = extension(mimetype) || 'jpg'; + const detected = await fromBuffer(buffer); + if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) { + throw new HttpException({ msg: 'Unsupported file type.' }, 400); + } + const mimetype = detected.mime; + const ext = detected.ext; const getFile = await this.storage.uploadFile({ buffer, diff --git a/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts index 42f38004..cac8a803 100644 --- a/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts +++ b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts @@ -6,6 +6,19 @@ import mime from 'mime-types'; import { getExtension } from 'mime'; import { IUploadProvider } from './upload.interface'; import axios from 'axios'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); class CloudflareStorage implements IUploadProvider { private _client: S3Client; @@ -59,19 +72,20 @@ class CloudflareStorage implements IUploadProvider { async uploadSimple(path: string) { const loadImage = await fetch(path); - const contentType = - loadImage?.headers?.get('content-type') || - loadImage?.headers?.get('Content-Type'); - const extension = getExtension(contentType) || - path.split('?')[0].split('#')[0].split('.').pop() || - 'bin'; + const body = Buffer.from(await loadImage.arrayBuffer()); + const detected = await fromBuffer(body); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const extension = detected.ext; + const safeContentType = detected.mime; const id = makeId(10); const params = { Bucket: this._bucketName, Key: `${id}.${extension}`, - Body: Buffer.from(await loadImage.arrayBuffer()), - ContentType: contentType, + Body: body, + ContentType: safeContentType, ChecksumMode: 'DISABLED', }; @@ -83,8 +97,13 @@ class CloudflareStorage implements IUploadProvider { async uploadFile(file: Express.Multer.File): Promise { try { + const detected = await fromBuffer(file.buffer); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } const id = makeId(10); - const extension = mime.extension(file.mimetype) || ''; + const extension = detected.ext; + const safeContentType = detected.mime; // Create the PutObjectCommand to upload the file to Cloudflare R2 const command = new PutObjectCommand({ @@ -92,6 +111,7 @@ class CloudflareStorage implements IUploadProvider { ACL: 'public-read', Key: `${id}.${extension}`, Body: file.buffer, + ContentType: safeContentType, }); await this._client.send(command); diff --git a/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts index add65c38..c24c9504 100644 --- a/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts +++ b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts @@ -1,37 +1,58 @@ import { BadRequestException, - FileTypeValidator, Injectable, - MaxFileSizeValidator, - ParseFilePipe, PipeTransform, } from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); @Injectable() export class CustomFileValidationPipe implements PipeTransform { async transform(value: any) { - if (!value) { - throw 'No file provided.'; - } - - if (!value.mimetype) { + if (!value || typeof value !== 'object') { return value; } - // Set the maximum file size based on the MIME type - const maxSize = this.getMaxSize(value.mimetype); - const validation = - (value.mimetype.startsWith('image/') || - value.mimetype.startsWith('video/mp4')) && - value.size <= maxSize; - - if (validation) { + // Skip non-file parameters (org, body, query, etc.) + if (!('buffer' in value) && !('mimetype' in value) && !('fieldname' in value)) { return value; } - throw new BadRequestException( - `File size exceeds the maximum allowed size of ${maxSize} bytes.` - ); + if (!value.buffer || !Buffer.isBuffer(value.buffer)) { + throw new BadRequestException('Invalid file upload.'); + } + + const detected = await fromBuffer(value.buffer); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new BadRequestException('Unsupported file type.'); + } + + const maxSize = this.getMaxSize(detected.mime); + if (value.size > maxSize) { + throw new BadRequestException( + `File size exceeds the maximum allowed size of ${maxSize} bytes.` + ); + } + + value.mimetype = detected.mime; + const safeBase = (value.originalname || 'upload') + .replace(/\.[^./\\]*$/, '') + .replace(/[\\/]/g, '_') + .slice(0, 100) || 'upload'; + value.originalname = `${safeBase}.${detected.ext}`; + + return value; } private getMaxSize(mimeType: string): number { diff --git a/libraries/nestjs-libraries/src/upload/local.storage.ts b/libraries/nestjs-libraries/src/upload/local.storage.ts index a03c1bd9..6d8007bb 100644 --- a/libraries/nestjs-libraries/src/upload/local.storage.ts +++ b/libraries/nestjs-libraries/src/upload/local.storage.ts @@ -3,6 +3,19 @@ import { mkdirSync, unlink, writeFileSync } from 'fs'; // @ts-ignore import mime from 'mime'; import { extname } from 'path'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const LOCAL_STORAGE_ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); export class LocalStorage implements IUploadProvider { constructor(private uploadDirectory: string) {} @@ -39,6 +52,13 @@ export class LocalStorage implements IUploadProvider { async uploadFile(file: Express.Multer.File): Promise { try { + const detected = await fromBuffer(file.buffer); + if (!detected || !LOCAL_STORAGE_ALLOWED_MIME.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const safeExt = `.${detected.ext}`; + const safeMime = detected.mime; + const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); @@ -53,19 +73,16 @@ export class LocalStorage implements IUploadProvider { .map(() => Math.round(Math.random() * 16).toString(16)) .join(''); - const filePath = `${dir}/${randomName}${extname(file.originalname)}`; - const publicPath = `${innerPath}/${randomName}${extname( - file.originalname - )}`; + const filePath = `${dir}/${randomName}${safeExt}`; + const publicPath = `${innerPath}/${randomName}${safeExt}`; - // Logic to save the file to the filesystem goes here writeFileSync(filePath, file.buffer); return { - filename: `${randomName}${extname(file.originalname)}`, + filename: `${randomName}${safeExt}`, path: process.env.FRONTEND_URL + '/uploads' + publicPath, - mimetype: file.mimetype, - originalname: file.originalname, + mimetype: safeMime, + originalname: `${randomName}${safeExt}`, }; } catch (err) { console.error('Error uploading file to Local Storage:', err); diff --git a/libraries/nestjs-libraries/src/upload/r2.uploader.ts b/libraries/nestjs-libraries/src/upload/r2.uploader.ts index 482ef38b..a0ba2250 100644 --- a/libraries/nestjs-libraries/src/upload/r2.uploader.ts +++ b/libraries/nestjs-libraries/src/upload/r2.uploader.ts @@ -6,12 +6,34 @@ import { CompleteMultipartUploadCommand, AbortMultipartUploadCommand, PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Request, Response } from 'express'; import crypto from 'crypto'; import path from 'path'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_EXT_TO_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', +}; + +function normalizeExtension(filename: string): string | null { + const ext = path.extname(filename || '').toLowerCase(); + return ALLOWED_EXT_TO_MIME[ext] ? ext : null; +} const { CLOUDFLARE_ACCOUNT_ID, @@ -60,16 +82,21 @@ export default async function handleR2Upload( export async function simpleUpload( data: Buffer, originalFilename: string, - contentType: string + _contentType: string ) { - const fileExtension = path.extname(originalFilename); // Extract extension - const randomFilename = generateRandomString() + fileExtension; // Append extension + const detected = await fromBuffer(data); + if (!detected || !Object.values(ALLOWED_EXT_TO_MIME).includes(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const fileExtension = `.${detected.ext}`; + const safeContentType = detected.mime; + const randomFilename = generateRandomString() + fileExtension; const params = { Bucket: CLOUDFLARE_BUCKETNAME, Key: randomFilename, Body: data, - ContentType: contentType, + ContentType: safeContentType, }; const command = new PutObjectCommand({ ...params }); @@ -79,15 +106,19 @@ export async function simpleUpload( } export async function createMultipartUpload(req: Request, res: Response) { - const { file, fileHash, contentType } = req.body; - const fileExtension = path.extname(file.name); // Extract extension - const randomFilename = generateRandomString() + fileExtension; // Append extension + const { file, fileHash } = req.body; + const safeExt = normalizeExtension(file?.name || ''); + if (!safeExt) { + return res.status(400).json({ message: 'Unsupported file type.' }); + } + const safeContentType = ALLOWED_EXT_TO_MIME[safeExt]; + const randomFilename = generateRandomString() + safeExt; try { const params = { Bucket: CLOUDFLARE_BUCKETNAME, Key: `${randomFilename}`, - ContentType: contentType, + ContentType: safeContentType, Metadata: { 'x-amz-meta-file-hash': fileHash, }, @@ -159,13 +190,6 @@ export async function completeMultipartUpload(req: Request, res: Response) { const { key, uploadId, parts } = req.body; try { - const params = { - Bucket: CLOUDFLARE_BUCKETNAME, - Key: key, - UploadId: uploadId, - MultipartUpload: { Parts: parts }, - }; - const command = new CompleteMultipartUploadCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key, @@ -173,6 +197,40 @@ export async function completeMultipartUpload(req: Request, res: Response) { MultipartUpload: { Parts: parts }, }); const response = await R2.send(command); + + const safeExt = normalizeExtension(key || ''); + if (!safeExt) { + await R2.send( + new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key }) + ); + return res.status(400).json({ message: 'Unsupported file type.' }); + } + const expectedMime = ALLOWED_EXT_TO_MIME[safeExt]; + + const head = await R2.send( + new GetObjectCommand({ + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + Range: 'bytes=0-4100', + }) + ); + const chunks: Buffer[] = []; + // @ts-ignore + for await (const chunk of head.Body as AsyncIterable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const prefix = Buffer.concat(chunks); + const detected = await fromBuffer(prefix); + + if (!detected || detected.mime !== expectedMime) { + await R2.send( + new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key }) + ); + return res + .status(400) + .json({ message: 'File contents do not match declared type.' }); + } + response.Location = process.env.CLOUDFLARE_BUCKET_URL + '/' + diff --git a/package.json b/package.json index 5686470d..717083f2 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "lodash": "^4.17.21", "mastra": "^1.3.19", "md5": "^2.3.0", + "file-type": "^16.5.4", "mime": "^3.0.0", "mime-types": "^2.1.35", "multer": "^1.4.5-lts.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dc1dcac..ab9b5933 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,6 +394,9 @@ importers: fast-xml-parser: specifier: ^4.5.1 version: 4.5.4 + file-type: + specifier: ^16.5.4 + version: 16.5.4 google-auth-library: specifier: ^9.11.0 version: 9.15.1 diff --git a/var/docker/nginx.conf b/var/docker/nginx.conf index 346ef62b..2adaa125 100644 --- a/var/docker/nginx.conf +++ b/var/docker/nginx.conf @@ -35,6 +35,19 @@ http { location /uploads/ { alias /uploads/; + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'none'; script-src 'none'; frame-ancestors 'none'; sandbox" always; + types { + image/jpeg jpg jpeg; + image/png png; + image/gif gif; + image/webp webp; + image/avif avif; + image/bmp bmp; + image/tiff tif tiff; + video/mp4 mp4; + } + default_type application/octet-stream; } location / {