Security: Add MIME type validation and logging to ReceiptManager

- Add magic bytes validation to prevent file extension spoofing
- Add ILogger for proper error logging in background parsing
- Log background parsing success/failure with receipt ID

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 21:11:13 -05:00
parent 1de3920db4
commit d9e883a9f3

View File

@@ -23,15 +23,37 @@ namespace MoneyMap.Services
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ReceiptManager> _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<string, byte[][]> 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<ReceiptManager> 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<IReceiptParser>();
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<bool> 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))