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:
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user