Private
Public Access
1
0

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>
This commit is contained in:
Catherine Renelle
2026-02-16 12:53:20 -05:00
parent fd14473125
commit 2f389ab8e9
19 changed files with 345 additions and 50 deletions
+29 -8
View File
@@ -78,16 +78,37 @@ public class FileWatcherService : BackgroundService
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);
var account = await db.Accounts
.Where(a => a.IsActive && !a.IsClosed)
.FirstOrDefaultAsync(a => a.Name.ToLower() == fileNameWithoutExt!.ToLower());
string? targetUsername = null;
string accountMatch = fileNameWithoutExt!;
// Fall back to first active account
account ??= await db.Accounts
.Where(a => a.IsActive && !a.IsClosed)
.OrderBy(a => a.SortOrder)
.FirstOrDefaultAsync();
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)
{