|
1 | 1 | import { buildForwardHeadersAsync } from '@/lib/auth'; |
2 | 2 | import { BACKEND_URL } from '@/lib/config'; |
3 | 3 | import { NextRequest } from 'next/server'; |
| 4 | +import { fileTypeFromBuffer } from 'file-type'; |
4 | 5 |
|
5 | 6 | // Maximum file sizes based on type |
6 | 7 | // SDK has 1MB JSON limit, base64 adds ~33% overhead, plus JSON structure overhead |
@@ -84,6 +85,57 @@ function isValidUrl(urlString: string): boolean { |
84 | 85 | } |
85 | 86 | } |
86 | 87 |
|
| 88 | +// Validate file content type via magic bytes |
| 89 | +// Returns the actual MIME type detected from file content, or null if detection fails |
| 90 | +// This prevents Content-Type header spoofing attacks |
| 91 | +async function validateFileType(buffer: ArrayBuffer, claimedType: string): Promise<string> { |
| 92 | + try { |
| 93 | + // Convert ArrayBuffer to Uint8Array for file-type library |
| 94 | + const uint8Array = new Uint8Array(buffer); |
| 95 | + |
| 96 | + // Detect actual file type from magic bytes |
| 97 | + const detected = await fileTypeFromBuffer(uint8Array); |
| 98 | + |
| 99 | + if (!detected) { |
| 100 | + // If detection fails, treat as plain text/binary |
| 101 | + // Allow the claimed type only if it's text-based or generic binary |
| 102 | + if (claimedType.startsWith('text/') || claimedType === 'application/octet-stream') { |
| 103 | + return claimedType; |
| 104 | + } |
| 105 | + // For other types, reject if we can't verify |
| 106 | + throw new Error('Unable to verify file type. File may be corrupted or unsupported.'); |
| 107 | + } |
| 108 | + |
| 109 | + // Normalize both types for comparison (remove parameters like charset) |
| 110 | + const normalizedClaimed = claimedType.split(';')[0].trim().toLowerCase(); |
| 111 | + const normalizedDetected = detected.mime.toLowerCase(); |
| 112 | + |
| 113 | + // Check if types match |
| 114 | + if (normalizedClaimed !== normalizedDetected) { |
| 115 | + // Special case: allow jpeg/jpg variations |
| 116 | + const isJpegVariant = (type: string) => type === 'image/jpeg' || type === 'image/jpg'; |
| 117 | + if (isJpegVariant(normalizedClaimed) && isJpegVariant(normalizedDetected)) { |
| 118 | + return detected.mime; |
| 119 | + } |
| 120 | + |
| 121 | + // Types don't match - reject |
| 122 | + throw new Error( |
| 123 | + `Content-Type mismatch: claimed '${normalizedClaimed}' but detected '${normalizedDetected}'. ` + |
| 124 | + `This may indicate a malicious file or incorrect Content-Type header.` |
| 125 | + ); |
| 126 | + } |
| 127 | + |
| 128 | + // Use the detected type (more trustworthy than header) |
| 129 | + return detected.mime; |
| 130 | + } catch (error) { |
| 131 | + // Re-throw validation errors |
| 132 | + if (error instanceof Error) { |
| 133 | + throw error; |
| 134 | + } |
| 135 | + throw new Error('File type validation failed'); |
| 136 | + } |
| 137 | +} |
| 138 | + |
87 | 139 | // Compress image if it exceeds size limit |
88 | 140 | // Returns compressed image buffer or original if already small enough, along with compression metadata |
89 | 141 | // IMPORTANT: Compression preserves original format (PNG stays PNG, JPEG stays JPEG, etc.) |
@@ -311,15 +363,33 @@ export async function POST( |
311 | 363 | // Sanitize filename to prevent path traversal attacks |
312 | 364 | const rawFilename = (formData.get('filename') as string) || file.name; |
313 | 365 | const filename = sanitizeFilename(rawFilename); |
314 | | - const contentType = file.type || 'application/octet-stream'; |
| 366 | + const claimedContentType = file.type || 'application/octet-stream'; |
| 367 | + const fileArrayBuffer = await file.arrayBuffer(); |
315 | 368 |
|
316 | | - // Compress and validate file |
| 369 | + // Validate file type via magic bytes to prevent malicious file uploads |
| 370 | + let validatedContentType: string; |
| 371 | + try { |
| 372 | + validatedContentType = await validateFileType(fileArrayBuffer, claimedContentType); |
| 373 | + } catch (error) { |
| 374 | + return new Response( |
| 375 | + JSON.stringify({ |
| 376 | + error: error instanceof Error ? error.message : 'File type validation failed', |
| 377 | + details: 'The file content does not match the claimed file type' |
| 378 | + }), |
| 379 | + { |
| 380 | + status: 400, |
| 381 | + headers: { 'Content-Type': 'application/json' }, |
| 382 | + } |
| 383 | + ); |
| 384 | + } |
| 385 | + |
| 386 | + // Compress and validate file size |
317 | 387 | let fileBuffer: ArrayBuffer; |
318 | 388 | let finalContentType: string; |
319 | 389 | let compressionInfo: { compressed: boolean; originalSize: number; finalSize: number }; |
320 | 390 |
|
321 | 391 | try { |
322 | | - const result = await compressAndValidate(await file.arrayBuffer(), contentType); |
| 392 | + const result = await compressAndValidate(fileArrayBuffer, validatedContentType); |
323 | 393 | fileBuffer = result.buffer; |
324 | 394 | finalContentType = result.contentType; |
325 | 395 | compressionInfo = result.compressionInfo; |
@@ -394,15 +464,33 @@ export async function POST( |
394 | 464 | }); |
395 | 465 | } |
396 | 466 |
|
397 | | - const contentType = fileResp.headers.get('content-type') || 'application/octet-stream'; |
| 467 | + const claimedContentType = fileResp.headers.get('content-type') || 'application/octet-stream'; |
| 468 | + const fileArrayBuffer = await fileResp.arrayBuffer(); |
| 469 | + |
| 470 | + // Validate file type via magic bytes to prevent Content-Type spoofing |
| 471 | + let validatedContentType: string; |
| 472 | + try { |
| 473 | + validatedContentType = await validateFileType(fileArrayBuffer, claimedContentType); |
| 474 | + } catch (error) { |
| 475 | + return new Response( |
| 476 | + JSON.stringify({ |
| 477 | + error: error instanceof Error ? error.message : 'File type validation failed', |
| 478 | + details: 'The file content does not match the claimed Content-Type header' |
| 479 | + }), |
| 480 | + { |
| 481 | + status: 400, |
| 482 | + headers: { 'Content-Type': 'application/json' }, |
| 483 | + } |
| 484 | + ); |
| 485 | + } |
398 | 486 |
|
399 | | - // Compress and validate file |
| 487 | + // Compress and validate file size |
400 | 488 | let fileBuffer: ArrayBuffer; |
401 | 489 | let finalContentType: string; |
402 | 490 | let compressionInfo: { compressed: boolean; originalSize: number; finalSize: number }; |
403 | 491 |
|
404 | 492 | try { |
405 | | - const result = await compressAndValidate(await fileResp.arrayBuffer(), contentType); |
| 493 | + const result = await compressAndValidate(fileArrayBuffer, validatedContentType); |
406 | 494 | fileBuffer = result.buffer; |
407 | 495 | finalContentType = result.contentType; |
408 | 496 | compressionInfo = result.compressionInfo; |
|
0 commit comments