Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/FileWatcherService.cs
T
Catherine Renelle 2f389ab8e9 Security hardening: fix IDOR vulnerabilities, add rate limiting, SSRF protection, and encryption upgrades
- Fix ReconciliationController and BankSync LinkAccount/UpdateLinkedAccount IDOR (verify account ownership)
- Add ASP.NET Core rate limiting: strict on auth endpoints, global on all API routes
- Harden SSRF validation on Ollama URL (IPv6-mapped bypass, link-local, 0.0.0.0 blocking)
- Upgrade encryption from AES-CBC to AES-GCM with HKDF key derivation (backward-compatible decrypt)
- Add startup validation that rejects default JWT/encryption keys in Production
- Add security headers (X-Frame-Options, CSP, HSTS, Cache-Control, nosniff) to API and nginx
- Mask account numbers in API responses (show last 4 digits only)
- Add password strength validation (min 8 chars) and BCrypt work factor 12
- Hash refresh tokens (SHA-256) before database storage
- Set JWT ClockSkew to zero for immediate expiry enforcement
- Add file upload size limit (10 MB) and filename sanitization
- Cap transaction search PageSize to 200
- Fix FileWatcherService cross-user account matching in multi-user deployments
- Sanitize console.error calls to prevent token leakage in browser logs
- Parameterize raw SQL in SimpleFinConnectionMigration
- Make AuthService.GenerateAuthResponse fully async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:53:20 -05:00

187 lines
6.9 KiB
C#

using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Purrse.Api.Hubs;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
namespace Purrse.Api.Services;
public class FileWatcherService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FileWatcherService> _logger;
private readonly IHubContext<NotificationHub> _hubContext;
private readonly string _importsPath;
private FileSystemWatcher? _watcher;
private static readonly string[] SupportedExtensions = { ".ofx", ".qfx", ".csv", ".qif" };
public FileWatcherService(
IServiceScopeFactory scopeFactory,
ILogger<FileWatcherService> logger,
IConfiguration config,
IHubContext<NotificationHub> hubContext)
{
_scopeFactory = scopeFactory;
_logger = logger;
_hubContext = hubContext;
_importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports");
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!Directory.Exists(_importsPath))
Directory.CreateDirectory(_importsPath);
var archivePath = Path.Combine(_importsPath, "archive");
if (!Directory.Exists(archivePath))
Directory.CreateDirectory(archivePath);
_watcher = new FileSystemWatcher(_importsPath)
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
IncludeSubdirectories = false
};
_watcher.Created += OnFileCreated;
_logger.LogInformation("File watcher started, monitoring: {Path}", _importsPath);
stoppingToken.Register(() =>
{
_watcher?.Dispose();
_logger.LogInformation("File watcher stopped");
});
return Task.CompletedTask;
}
private async void OnFileCreated(object sender, FileSystemEventArgs e)
{
try
{
var extension = Path.GetExtension(e.FullPath).ToLowerInvariant();
if (!SupportedExtensions.Contains(extension))
{
_logger.LogDebug("Ignoring non-supported file: {FileName}", e.Name);
return;
}
_logger.LogInformation("New import file detected: {FileName}", e.Name);
// Wait for file to finish writing
await WaitForFileReady(e.FullPath);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
var importService = scope.ServiceProvider.GetRequiredService<IImportService>();
// Find the target account by matching filename to account name
// In multi-user setups, the filename format is "username--accountname.ext"
// If no separator, match against all accounts (single-user mode)
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name);
string? targetUsername = null;
string accountMatch = fileNameWithoutExt!;
if (fileNameWithoutExt!.Contains("--"))
{
var parts = fileNameWithoutExt.Split("--", 2);
targetUsername = parts[0];
accountMatch = parts[1];
}
var accountQuery = db.Accounts
.Include(a => a.User)
.Where(a => a.IsActive && !a.IsClosed);
if (targetUsername != null)
accountQuery = accountQuery.Where(a => a.User.Username.ToLower() == targetUsername.ToLower());
var account = await accountQuery
.FirstOrDefaultAsync(a => a.Name.ToLower() == accountMatch.ToLower());
// Fall back to first active account only if no username filter (single-user mode)
if (account == null && targetUsername == null)
{
account = await db.Accounts
.Where(a => a.IsActive && !a.IsClosed)
.OrderBy(a => a.SortOrder)
.FirstOrDefaultAsync();
}
if (account == null)
{
_logger.LogWarning("No active account found for file import: {FileName}", e.Name);
return;
}
_logger.LogInformation("Importing {FileName} into account {AccountName} ({AccountId})",
e.Name, account.Name, account.Id);
await using var fileStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var result = await importService.UploadAsync(account.UserId, account.Id, e.Name!, fileStream);
_logger.LogInformation(
"File import complete: {FileName} - {Total} transactions ({New} new, {Duplicates} duplicates)",
e.Name, result.TotalTransactions, result.NewTransactions, result.PossibleDuplicates);
// Send SignalR notification to the account owner
await _hubContext.Clients.Group(account.UserId.ToString()).SendAsync("FileImportReady", new
{
batchId = result.BatchId,
fileName = e.Name,
accountId = account.Id,
accountName = account.Name,
totalTransactions = result.TotalTransactions,
newTransactions = result.NewTransactions,
possibleDuplicates = result.PossibleDuplicates
});
// Archive the processed file
ArchiveFile(e.FullPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing imported file: {FileName}", e.Name);
}
}
private static async Task WaitForFileReady(string filePath, int maxRetries = 30, int delayMs = 500)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None);
return; // File is ready
}
catch (IOException)
{
await Task.Delay(delayMs);
}
}
throw new IOException($"File {filePath} was not ready after {maxRetries * delayMs}ms");
}
private void ArchiveFile(string filePath)
{
try
{
var archivePath = Path.Combine(_importsPath, "archive");
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var fileName = Path.GetFileName(filePath);
var archiveFileName = $"{timestamp}_{fileName}";
var destination = Path.Combine(archivePath, archiveFileName);
File.Move(filePath, destination);
_logger.LogInformation("Archived import file to: {ArchivePath}", destination);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to archive file: {FilePath}", filePath);
}
}
}