Compare commits

..

1 commit

Author SHA1 Message Date
Santosh Bhandari
00b59ec2f4 fix: validate MCP attachment formats and align media allowlist
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
Reject non-mp4 / non-image attachment URLs in the MCP
integrationSchedulePostTool (previously accepted any URL). Introduce
a single ALLOWED_POST_MEDIA source of truth in helpers/has.extension
and derive VALID_POST_MEDIA_EXTENSIONS + VALID_POST_MEDIA_MIME_TYPES
from it, replacing duplicated constants in the file upload pipe, the
public-api URL upload controller, and the ValidUrlExtension DTO
validator. Error messages now consistently list the allowed
extensions across the public API and MCP.
2026-05-19 13:56:09 +05:45
5 changed files with 63 additions and 37 deletions

View file

@ -35,19 +35,9 @@ import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/n
import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto';
import { Readable } from 'stream';
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import { VALID_POST_MEDIA_MIME_TYPES } from '@gitroom/helpers/utils/has.extension';
// 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,
@ -108,7 +98,7 @@ export class PublicIntegrationsController {
}
const buffer = Buffer.from(await response.arrayBuffer());
const detected = await fromBuffer(buffer);
if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) {
if (!detected || !VALID_POST_MEDIA_MIME_TYPES.has(detected.mime)) {
throw new HttpException({ msg: 'Unsupported file type.' }, 400);
}
const mimetype = detected.mime;

View file

@ -8,3 +8,26 @@ export const hasExtension = (
const ext = extension.startsWith('.') ? extension : `.${extension}`;
return path.toLowerCase().indexOf(ext.toLowerCase()) > -1;
};
const ALLOWED_POST_MEDIA: ReadonlyArray<{ ext: string; mime: string }> = [
{ ext: 'png', mime: 'image/png' },
{ ext: 'jpg', mime: 'image/jpeg' },
{ ext: 'jpeg', mime: 'image/jpeg' },
{ ext: 'gif', mime: 'image/gif' },
{ ext: 'webp', mime: 'image/webp' },
{ ext: 'mp4', mime: 'video/mp4' },
];
export const VALID_POST_MEDIA_EXTENSIONS = ALLOWED_POST_MEDIA.map(
(m) => m.ext
);
export const VALID_POST_MEDIA_MIME_TYPES = new Set<string>(
ALLOWED_POST_MEDIA.map((m) => m.mime)
);
export const isValidPostMediaUrl = (
path: string | undefined | null
): boolean => {
return VALID_POST_MEDIA_EXTENSIONS.some((ext) => hasExtension(path, ext));
};

View file

@ -3,25 +3,20 @@ import {
ValidatorConstraintInterface,
ValidatorConstraint,
} from 'class-validator';
import { VALID_POST_MEDIA_EXTENSIONS } from './has.extension';
@ValidatorConstraint({ name: 'checkValidExtension', async: false })
export class ValidUrlExtension implements ValidatorConstraintInterface {
validate(text: string, args: ValidationArguments) {
return (
!!text?.split?.('?')?.[0].endsWith('.png') ||
!!text?.split?.('?')?.[0].endsWith('.jpg') ||
!!text?.split?.('?')?.[0].endsWith('.jpeg') ||
!!text?.split?.('?')?.[0].endsWith('.gif') ||
!!text?.split?.('?')?.[0].endsWith('.webp') ||
!!text?.split?.('?')?.[0].endsWith('.mp4')
);
const path = text?.split?.('?')?.[0]?.toLowerCase?.();
if (!path) return false;
return VALID_POST_MEDIA_EXTENSIONS.some((ext) => path.endsWith('.' + ext));
}
defaultMessage(args: ValidationArguments) {
// here you can provide default error message if validation failed
return (
'File must have a valid extension: .png, .jpg, .jpeg, .gif, .webp, or .mp4'
);
return `File must have a valid extension: ${VALID_POST_MEDIA_EXTENSIONS.map(
(ext) => '.' + ext
).join(', ')}`;
}
}

View file

@ -12,6 +12,10 @@ import { Integration } from '@prisma/client';
import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { weightedLength } from '@gitroom/helpers/utils/count.length';
import {
isValidPostMediaUrl,
VALID_POST_MEDIA_EXTENSIONS,
} from '@gitroom/helpers/utils/has.extension';
function countCharacters(text: string, type: string): number {
if (type !== 'x') {
@ -130,6 +134,19 @@ If the tools return errors, you would need to rerun it with the right parameters
).id;
const finalOutput = [];
const invalidAttachment = inputData.socialPost
.flatMap((p) => p.postsAndComments)
.flatMap((p) => p.attachments ?? [])
.find((url: string) => !isValidPostMediaUrl(url));
if (invalidAttachment) {
return {
errors: `Attachment "${invalidAttachment}" is not supported. Valid extensions: ${VALID_POST_MEDIA_EXTENSIONS.map(
(ext) => '.' + ext
).join(', ')}.`,
};
}
const integrations = {} as Record<string, Integration>;
for (const platform of inputData.socialPost) {
integrations[platform.integrationId] =

View file

@ -3,19 +3,16 @@ import {
Injectable,
PipeTransform,
} from '@nestjs/common';
import {
VALID_POST_MEDIA_EXTENSIONS,
VALID_POST_MEDIA_MIME_TYPES,
} from '@gitroom/helpers/utils/has.extension';
// 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',
]);
const ALLOWED_EXTENSIONS_MESSAGE = `Valid extensions: ${VALID_POST_MEDIA_EXTENSIONS
.map((ext) => '.' + ext)
.join(', ')}`;
@Injectable()
export class CustomFileValidationPipe implements PipeTransform {
@ -34,8 +31,10 @@ export class CustomFileValidationPipe implements PipeTransform {
}
const detected = await fromBuffer(value.buffer);
if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) {
throw new BadRequestException('Unsupported file type.');
if (!detected || !VALID_POST_MEDIA_MIME_TYPES.has(detected.mime)) {
throw new BadRequestException(
`Unsupported file type. ${ALLOWED_EXTENSIONS_MESSAGE}`
);
}
const maxSize = this.getMaxSize(detected.mime);
@ -61,7 +60,9 @@ export class CustomFileValidationPipe implements PipeTransform {
} else if (mimeType.startsWith('video/')) {
return 1024 * 1024 * 1024; // 1 GB
} else {
throw new BadRequestException('Unsupported file type.');
throw new BadRequestException(
`Unsupported file type. ${ALLOWED_EXTENSIONS_MESSAGE}`
);
}
}
}