Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import {
CustomFileValidationPipe,
getMaxSize,
isCompleteMp4,
INCOMPLETE_MP4_ERROR,
} from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import { ApiTags } from '@nestjs/swagger';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
Expand Down Expand Up @@ -138,6 +140,10 @@ export class PublicIntegrationsController {
throw new HttpException({ msg: 'File is too large.' }, 400);
}

if (detected.mime === 'video/mp4' && !isCompleteMp4(buffer)) {
throw new HttpException({ msg: INCOMPLETE_MP4_ERROR }, 400);
}

const mimetype = detected.mime;
const ext = detected.ext;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { z } from 'zod';
import { Injectable } from '@nestjs/common';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { getMaxSize } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import {
getMaxSize,
isCompleteMp4,
INCOMPLETE_MP4_ERROR,
} from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context';
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import { Readable } from 'stream';
Expand Down Expand Up @@ -102,6 +106,10 @@ so the attachment passes the upload-domain validation. Returns the hosted media
};
}

if (detected.mime === 'video/mp4' && !isCompleteMp4(buffer)) {
return { error: INCOMPLETE_MP4_ERROR };
}

const getFile = await this.storage.uploadFile({
buffer,
mimetype: detected.mime,
Expand Down
45 changes: 45 additions & 0 deletions libraries/nestjs-libraries/src/upload/custom.upload.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export class CustomFileValidationPipe implements PipeTransform {
);
}

if (detected.mime === 'video/mp4' && !isCompleteMp4(value.buffer)) {
throw new BadRequestException(INCOMPLETE_MP4_ERROR);
}

value.mimetype = detected.mime;
const safeBase = (value.originalname || 'upload')
.replace(/\.[^./\\]*$/, '')
Expand All @@ -57,6 +61,47 @@ export class CustomFileValidationPipe implements PipeTransform {

}

export const INCOMPLETE_MP4_ERROR =
'Video file is corrupted or incomplete (missing moov atom). This usually means the file was uploaded before the encoder finished writing it — wait for the render to complete and upload it again.';

// A playable mp4 must contain a top-level moov box (the index of the file).
// Encoders write it last (or rewrite the file with it first for faststart),
// so a file grabbed mid-encode has only ftyp + mdat and can't be played or
// published anywhere.
export function isCompleteMp4(buffer: Buffer): boolean {
let offset = 0;
while (offset + 8 <= buffer.length) {
let size: number = buffer.readUInt32BE(offset);
const type = buffer.toString('latin1', offset + 4, offset + 8);
if (type === 'moov') {
return true;
}
if (size === 0) {
// box extends to end of file
return false;
}
if (size === 1) {
if (offset + 16 > buffer.length) {
return false;
}
const largeSize = buffer.readBigUInt64BE(offset + 8);
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) {
return false;
}
size = Number(largeSize);
// a largesize box header is 16 bytes, so smaller values are malformed
if (size < 16) {
return false;
}
}
if (size < 8) {

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 22c51d3. Note the fix keeps the generic minimum at 8 for ordinary 32-bit boxes (8 is legal there) and enforces the 16-byte minimum only inside the largesize branch. Verified with a crafted file (largesize=9 with a misaligned moov planted after it) which is now rejected, while the real-world valid/corrupt fixtures behave as before.

return false;
}
offset += size;
}
return false;
}

export function getMaxSize(mimeType: string): number {
if (mimeType.startsWith('image/')) {
return 10 * 1024 * 1024; // 10 MB
Expand Down
Loading