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:
@@ -23,15 +23,37 @@ namespace MoneyMap.Services
|
|||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly ILogger<ReceiptManager> _logger;
|
||||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
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;
|
_db = db;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetReceiptsBasePath()
|
private string GetReceiptsBasePath()
|
||||||
@@ -69,6 +91,10 @@ namespace MoneyMap.Services
|
|||||||
if (!AllowedExtensions.Contains(extension))
|
if (!AllowedExtensions.Contains(extension))
|
||||||
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
|
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
|
// Create receipts directory if it doesn't exist
|
||||||
var receiptsBasePath = GetReceiptsBasePath();
|
var receiptsBasePath = GetReceiptsBasePath();
|
||||||
if (!Directory.Exists(receiptsBasePath))
|
if (!Directory.Exists(receiptsBasePath))
|
||||||
@@ -133,10 +159,11 @@ namespace MoneyMap.Services
|
|||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var parser = scope.ServiceProvider.GetRequiredService<IReceiptParser>();
|
var parser = scope.ServiceProvider.GetRequiredService<IReceiptParser>();
|
||||||
await parser.ParseReceiptAsync(receipt.Id);
|
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;
|
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)
|
private static string SanitizeFileName(string fileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
|||||||
Reference in New Issue
Block a user