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
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:
parent
e3b3b82fae
commit
3ea302202d
9 changed files with 207 additions and 56 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
'/' +
|
||||
|
|
|
|||
|
|
@ -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
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 / {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue