feat: fix advisory
Some checks failed
Build Containers / build-containers-common (push) Has been cancelled
Build / build (22.12.0) (push) Has been cancelled
Build Containers / build-containers (amd64, ubuntu-latest) (push) Has been cancelled
Build Containers / build-containers (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build Containers / build-container-manifest (push) Has been cancelled

This commit is contained in:
Nevo David 2026-04-12 10:27:01 +07:00
parent e3b3b82fae
commit 3ea302202d
9 changed files with 207 additions and 56 deletions

View file

@ -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,

View file

@ -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<string>([
'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,

View file

@ -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<string>([
'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<any> {
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);

View file

@ -1,39 +1,60 @@
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<string>([
'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;
}
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 {
if (mimeType.startsWith('image/')) {
return 10 * 1024 * 1024; // 10 MB

View file

@ -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<string>([
'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<any> {
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);

View file

@ -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<string, string> = {
'.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<Buffer>) {
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 +
'/' +

View file

@ -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",

3
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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 / {