fix(payments): harden payment flow with idempotency keys, amount validation, and magic byte file validation

- Add dedicated idempotencyKey column with unique constraint (userId, provider, idempotencyKey) to prevent duplicate payments at DB level
- Add @Min(1) @Max(100B) validators on amountVND in CreatePaymentDto to reject invalid amounts at API boundary
- Replace read-check-write callback handler with atomic updateIfStatus to eliminate race condition on concurrent callbacks
- Add magic byte verification in FileValidationPipe to validate file content matches declared MIME type server-side

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:18:26 +07:00
parent be0deddeed
commit 9583d1cb66
9 changed files with 148 additions and 43 deletions

View File

@@ -14,6 +14,8 @@ export interface FileValidationOptions {
maxSizeBytes?: number;
/** Allowed MIME types. Default: common image types + PDF */
allowedMimeTypes?: string[];
/** Whether to verify file content matches declared MIME type via magic bytes. Default: true */
verifyMagicBytes?: boolean;
}
const DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
@@ -25,18 +27,33 @@ const DEFAULT_ALLOWED_MIMES = [
'application/pdf',
];
/** Magic byte signatures for supported file types. */
const MAGIC_BYTES: Record<string, { offset: number; bytes: number[] }[]> = {
'image/jpeg': [{ offset: 0, bytes: [0xFF, 0xD8, 0xFF] }],
'image/png': [{ offset: 0, bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] }],
'image/webp': [{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }], // "RIFF" header
'image/gif': [
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] }, // GIF89a
],
'application/pdf': [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }], // %PDF
'video/mp4': [{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] }], // "ftyp" at offset 4
};
/**
* Validates uploaded files for size and MIME type to prevent
* malicious file uploads and resource exhaustion.
* Validates uploaded files for size, MIME type, and file content
* (magic bytes) to prevent malicious file uploads and resource exhaustion.
*/
@Injectable()
export class FileValidationPipe implements PipeTransform {
private readonly maxSize: number;
private readonly allowedMimes: string[];
private readonly verifyMagicBytes: boolean;
constructor(options?: FileValidationOptions) {
this.maxSize = options?.maxSizeBytes ?? DEFAULT_MAX_SIZE;
this.allowedMimes = options?.allowedMimeTypes ?? DEFAULT_ALLOWED_MIMES;
this.verifyMagicBytes = options?.verifyMagicBytes ?? true;
}
transform(file: UploadedFile): UploadedFile {
@@ -56,6 +73,29 @@ export class FileValidationPipe implements PipeTransform {
);
}
if (this.verifyMagicBytes) {
this.validateMagicBytes(file);
}
return file;
}
private validateMagicBytes(file: UploadedFile): void {
const signatures = MAGIC_BYTES[file.mimetype];
if (!signatures) {
// No known signature for this type — skip magic byte check
return;
}
const matches = signatures.some((sig) => {
if (file.buffer.length < sig.offset + sig.bytes.length) return false;
return sig.bytes.every((byte, i) => file.buffer[sig.offset + i] === byte);
});
if (!matches) {
throw new BadRequestException(
`File content does not match declared type '${file.mimetype}'. The file may be corrupted or mislabeled.`,
);
}
}
}