diff --git a/MoneyMap/Services/ReceiptManager.cs b/MoneyMap/Services/ReceiptManager.cs index 5033cd2..410aef1 100644 --- a/MoneyMap/Services/ReceiptManager.cs +++ b/MoneyMap/Services/ReceiptManager.cs @@ -23,15 +23,37 @@ namespace MoneyMap.Services private readonly IWebHostEnvironment _environment; private readonly IConfiguration _configuration; private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; private const long MaxFileSize = 10 * 1024 * 1024; // 10MB private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" }; - public ReceiptManager(MoneyMapContext db, IWebHostEnvironment environment, IConfiguration configuration, IServiceProvider serviceProvider) + // Magic bytes for file type validation (prevents extension spoofing) + private static readonly Dictionary FileSignatures = new() + { + { ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, + { ".jpeg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, + { ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } }, + { ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a + { ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF + { ".heic", new[] { + new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic + new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic (variant) + new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check) + }} + }; + + public ReceiptManager( + MoneyMapContext db, + IWebHostEnvironment environment, + IConfiguration configuration, + IServiceProvider serviceProvider, + ILogger logger) { _db = db; _environment = environment; _configuration = configuration; _serviceProvider = serviceProvider; + _logger = logger; } private string GetReceiptsBasePath() @@ -69,6 +91,10 @@ namespace MoneyMap.Services if (!AllowedExtensions.Contains(extension)) return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}"); + // Validate file content matches extension (magic bytes check) + if (!await ValidateFileSignatureAsync(file, extension)) + return ReceiptUploadResult.Failure($"File content does not match {extension} format. The file may be corrupted or have an incorrect extension."); + // Create receipts directory if it doesn't exist var receiptsBasePath = GetReceiptsBasePath(); if (!Directory.Exists(receiptsBasePath)) @@ -133,10 +159,11 @@ namespace MoneyMap.Services using var scope = _serviceProvider.CreateScope(); var parser = scope.ServiceProvider.GetRequiredService(); await parser.ParseReceiptAsync(receipt.Id); + _logger.LogInformation("Background parsing completed for receipt {ReceiptId}", receipt.Id); } - catch + catch (Exception ex) { - // Silently fail - upload was successful, parsing is optional + _logger.LogError(ex, "Background parsing failed for receipt {ReceiptId}: {Message}", receipt.Id, ex.Message); } }); @@ -224,6 +251,23 @@ namespace MoneyMap.Services return true; } + private static async Task ValidateFileSignatureAsync(IFormFile file, string extension) + { + if (!FileSignatures.TryGetValue(extension, out var signatures)) + return true; // No signature check for unknown extensions + + var maxSignatureLength = signatures.Max(s => s.Length); + var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)]; + + await using var stream = file.OpenReadStream(); + _ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length)); + + // Check if file starts with any of the valid signatures for this extension + return signatures.Any(signature => + headerBytes.Length >= signature.Length && + headerBytes.Take(signature.Length).SequenceEqual(signature)); + } + private static string SanitizeFileName(string fileName) { if (string.IsNullOrWhiteSpace(fileName))