From 2f389ab8e94bd868e3396670f34b7ead744bd1c8 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:53:20 -0500 Subject: [PATCH] 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 --- docker-compose.yml | 2 +- frontend/nginx.conf | 7 ++ frontend/src/components/chat/ChatPanel.vue | 6 +- frontend/src/views/chat/ChatView.vue | 6 +- frontend/src/views/reports/ReportsView.vue | 2 +- src/Purrse.Api/Controllers/AuthController.cs | 2 + .../Controllers/ImportsController.cs | 4 +- .../Controllers/ReconciliationController.cs | 8 +++ src/Purrse.Api/Program.cs | 62 +++++++++++++++- src/Purrse.Api/Services/AccountService.cs | 13 +++- .../Services/AiCategorizationService.cs | 60 ++++++++++++++++ src/Purrse.Api/Services/AuthService.cs | 35 ++++++--- src/Purrse.Api/Services/BankSyncService.cs | 21 +++++- src/Purrse.Api/Services/ChatService.cs | 50 +++++++++++++ src/Purrse.Api/Services/EncryptionService.cs | 72 +++++++++++++++---- src/Purrse.Api/Services/FileWatcherService.cs | 37 +++++++--- .../Services/SimpleFinConnectionMigration.cs | 4 +- src/Purrse.Api/Services/TransactionService.cs | 2 + .../Interfaces/Services/IAccountService.cs | 2 +- 19 files changed, 345 insertions(+), 50 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9b4cf9f..49b7c75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: dockerfile: src/Purrse.Api/Dockerfile container_name: purrse-api environment: - - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=purrse;Username=purrse;Password=${DB_PASSWORD:-purrse_dev} + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=purrse;Username=purrse;Password=${DB_PASSWORD:-purrse_dev};Ssl Mode=Prefer;Trust Server Certificate=true - Jwt__Key=${JWT_KEY:-PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!} - Jwt__Issuer=Purrse - Jwt__Audience=Purrse diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 02bbb9b..81b1c33 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,13 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.plaid.com; connect-src 'self' wss:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;" always; + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/src/components/chat/ChatPanel.vue b/frontend/src/components/chat/ChatPanel.vue index f0443a0..be0369b 100644 --- a/frontend/src/components/chat/ChatPanel.vue +++ b/frontend/src/components/chat/ChatPanel.vue @@ -237,7 +237,7 @@ async function loadConversations() { conversations.value = data conversationsLoaded.value = true } catch (err) { - console.error('Failed to load conversations', err) + console.error('Failed to load conversations:', err instanceof Error ? err.message : 'Unknown error') } } @@ -248,7 +248,7 @@ async function loadConversation(id: string) { messages.value = data.messages await scrollToBottom() } catch (err) { - console.error('Failed to load conversation', err) + console.error('Failed to load conversation:', err instanceof Error ? err.message : 'Unknown error') } } @@ -322,7 +322,7 @@ async function deleteConversation(id: string) { startNewChat() } } catch (err) { - console.error('Failed to delete conversation', err) + console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error') } } diff --git a/frontend/src/views/chat/ChatView.vue b/frontend/src/views/chat/ChatView.vue index 0a8b007..167a42d 100644 --- a/frontend/src/views/chat/ChatView.vue +++ b/frontend/src/views/chat/ChatView.vue @@ -162,7 +162,7 @@ async function loadConversations() { const { data } = await chatApi.getConversations() conversations.value = data } catch (err) { - console.error('Failed to load conversations', err) + console.error('Failed to load conversations:', err instanceof Error ? err.message : 'Unknown error') } } @@ -173,7 +173,7 @@ async function loadConversation(id: string) { messages.value = data.messages await scrollToBottom() } catch (err) { - console.error('Failed to load conversation', err) + console.error('Failed to load conversation:', err instanceof Error ? err.message : 'Unknown error') } } @@ -244,7 +244,7 @@ async function deleteConversation(id: string) { startNewChat() } } catch (err) { - console.error('Failed to delete conversation', err) + console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error') } } diff --git a/frontend/src/views/reports/ReportsView.vue b/frontend/src/views/reports/ReportsView.vue index 4c53f78..4c070f1 100644 --- a/frontend/src/views/reports/ReportsView.vue +++ b/frontend/src/views/reports/ReportsView.vue @@ -553,7 +553,7 @@ async function fetchReport(request: Promise<{ data: T }>): Promise } catch (err: any) { const status = err?.response?.status if (status && status >= 500) { - console.error('Report request failed:', err) + console.error('Report request failed:', err instanceof Error ? err.message : 'Unknown error') return null } // 4xx (including 404) treated as no data diff --git a/src/Purrse.Api/Controllers/AuthController.cs b/src/Purrse.Api/Controllers/AuthController.cs index a7427cf..51b82a2 100644 --- a/src/Purrse.Api/Controllers/AuthController.cs +++ b/src/Purrse.Api/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Purrse.Core.DTOs; using Purrse.Core.Interfaces.Services; using System.Security.Claims; @@ -8,6 +9,7 @@ namespace Purrse.Api.Controllers; [ApiController] [Route("api/[controller]")] +[EnableRateLimiting("auth")] public class AuthController : ControllerBase { private readonly IAuthService _authService; diff --git a/src/Purrse.Api/Controllers/ImportsController.cs b/src/Purrse.Api/Controllers/ImportsController.cs index 90e8d51..b95c5b3 100644 --- a/src/Purrse.Api/Controllers/ImportsController.cs +++ b/src/Purrse.Api/Controllers/ImportsController.cs @@ -17,13 +17,15 @@ public class ImportsController : ControllerBase public ImportsController(IImportService importService) => _importService = importService; [HttpPost("upload")] + [RequestSizeLimit(10_000_000)] // 10 MB public async Task> Upload([FromQuery] Guid accountId, IFormFile file) { if (file == null || file.Length == 0) return BadRequest(new { error = "No file uploaded" }); using var stream = file.OpenReadStream(); - var result = await _importService.UploadAsync(UserId, accountId, file.FileName, stream); + var safeFileName = Path.GetFileName(file.FileName); + var result = await _importService.UploadAsync(UserId, accountId, safeFileName, stream); return Ok(result); } diff --git a/src/Purrse.Api/Controllers/ReconciliationController.cs b/src/Purrse.Api/Controllers/ReconciliationController.cs index 6083f99..03b3b64 100644 --- a/src/Purrse.Api/Controllers/ReconciliationController.cs +++ b/src/Purrse.Api/Controllers/ReconciliationController.cs @@ -57,6 +57,10 @@ public class ReconciliationController : ControllerBase [HttpPost("{accountId}/complete")] public async Task Complete(Guid accountId, [FromQuery] Guid reconciliationId) { + // Verify the account belongs to the authenticated user + var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId) + ?? throw new KeyNotFoundException("Account not found"); + var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId) ?? throw new KeyNotFoundException("Reconciliation not found"); @@ -80,6 +84,10 @@ public class ReconciliationController : ControllerBase [HttpPost("{accountId}/cancel")] public async Task Cancel(Guid accountId, [FromQuery] Guid reconciliationId) { + // Verify the account belongs to the authenticated user + var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId) + ?? throw new KeyNotFoundException("Account not found"); + var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted) ?? throw new KeyNotFoundException("Reconciliation not found"); diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index 65c0c44..ed60208 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -1,6 +1,8 @@ using System.Text; using System.Threading.Channels; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Purrse.Api.Hubs; @@ -33,7 +35,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse", ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse", - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ClockSkew = TimeSpan.Zero }; // SignalR JWT support @@ -114,6 +117,48 @@ builder.Services.AddControllers() }); builder.Services.AddEndpointsApiExplorer(); +// Rate limiting +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + // Strict policy for auth endpoints (login, register, change-password) + options.AddPolicy("auth", context => + RateLimitPartition.GetSlidingWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 2 + })); + + // General policy for all other API endpoints + options.AddPolicy("api", context => + { + var userId = context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + return RateLimitPartition.GetSlidingWindowLimiter(userId, _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 4 + }); + }); +}); + +// Validate secrets are not defaults in Production +if (builder.Environment.IsProduction()) +{ + if (jwtKey == "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!") + throw new InvalidOperationException("JWT key must be changed from the default value in Production. Set the Jwt:Key configuration or JWT_KEY environment variable."); + + var encryptionKey = builder.Configuration["BankSync:EncryptionKey"]; + if (encryptionKey == "CHANGE_ME_32_CHAR_KEY_FOR_AES256!") + throw new InvalidOperationException("BankSync encryption key must be changed from the default value in Production. Set the BankSync:EncryptionKey configuration."); +} + var app = builder.Build(); // Auto-migrate database @@ -151,11 +196,24 @@ await pluginService.DiscoverAndLoadPluginsAsync(); app.UseMiddleware(); app.UseMiddleware(); +// Security headers +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none"; + context.Response.Headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; + context.Response.Headers["Cache-Control"] = "no-store"; + await next(); +}); + app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); +app.UseRateLimiter(); -app.MapControllers(); +app.MapControllers().RequireRateLimiting("api"); app.MapHub("/hubs/notifications"); app.Run(); diff --git a/src/Purrse.Api/Services/AccountService.cs b/src/Purrse.Api/Services/AccountService.cs index 60816c3..257cd18 100644 --- a/src/Purrse.Api/Services/AccountService.cs +++ b/src/Purrse.Api/Services/AccountService.cs @@ -114,9 +114,9 @@ public class AccountService : IAccountService return entries; } - public async Task RecalculateBalanceAsync(Guid accountId) + public async Task RecalculateBalanceAsync(Guid userId, Guid accountId) { - var account = await _db.Accounts.FindAsync(accountId); + var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId); if (account == null) return; var sum = await _db.Transactions @@ -129,9 +129,16 @@ public class AccountService : IAccountService } private static AccountResponse MapToResponse(Account a) => new( - a.Id, a.Name, a.Type, a.Institution, a.AccountNumber, + a.Id, a.Name, a.Type, a.Institution, MaskAccountNumber(a.AccountNumber), a.Balance, a.CreditLimit, a.InterestRate, a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt, a.LoanDetail != null ); + + private static string? MaskAccountNumber(string? accountNumber) + { + if (string.IsNullOrEmpty(accountNumber) || accountNumber.Length <= 4) + return accountNumber; + return new string('*', accountNumber.Length - 4) + accountNumber[^4..]; + } } diff --git a/src/Purrse.Api/Services/AiCategorizationService.cs b/src/Purrse.Api/Services/AiCategorizationService.cs index f449bc8..4c752d3 100644 --- a/src/Purrse.Api/Services/AiCategorizationService.cs +++ b/src/Purrse.Api/Services/AiCategorizationService.cs @@ -92,6 +92,7 @@ public class AiCategorizationService : IAiCategorizationService } settings.IsEnabled = request.IsEnabled; + ValidateOllamaUrl(request.OllamaUrl); settings.OllamaUrl = request.OllamaUrl; settings.ModelName = request.ModelName; settings.ConfidenceThreshold = request.ConfidenceThreshold; @@ -521,4 +522,63 @@ public class AiCategorizationService : IAiCategorizationService return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified); } + + private static void ValidateOllamaUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new InvalidOperationException("Invalid Ollama URL"); + + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new InvalidOperationException("Ollama URL must use http or https"); + + var host = uri.Host.ToLowerInvariant(); + + // Allow well-known safe hostnames + if (host == "localhost" || host == "host.docker.internal") + return; + + // Block known dangerous hostnames + if (host == "metadata.google.internal") + throw new InvalidOperationException("Ollama URL points to a blocked address"); + + if (System.Net.IPAddress.TryParse(host, out var ip)) + { + // Normalize IPv6-mapped IPv4 (e.g. ::ffff:169.254.169.254) to IPv4 + if (ip.IsIPv4MappedToIPv6) + ip = ip.MapToIPv4(); + + if (!IsAllowedIp(ip)) + throw new InvalidOperationException("Ollama URL must point to a local or private network address"); + return; + } + + // Allow Docker service names (simple hostnames without dots) + if (!host.Contains('.')) + return; + + throw new InvalidOperationException("Ollama URL must point to a local or private network address"); + } + + private static bool IsAllowedIp(System.Net.IPAddress ip) + { + // Only allow IPv4 private/loopback — block all public and IPv6 link-local + if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return System.Net.IPAddress.IsLoopback(ip); + + var bytes = ip.GetAddressBytes(); + + // Block 0.0.0.0, link-local (169.254.x.x), and any non-private + if (bytes[0] == 0) return false; // 0.0.0.0 + if (bytes[0] == 169 && bytes[1] == 254) return false; // link-local / cloud metadata + + // Allow loopback (127.x.x.x) + if (bytes[0] == 127) return true; + + // Allow private ranges: 10.x.x.x, 172.16-31.x.x, 192.168.x.x + if (bytes[0] == 10) return true; + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + if (bytes[0] == 192 && bytes[1] == 168) return true; + + return false; + } } diff --git a/src/Purrse.Api/Services/AuthService.cs b/src/Purrse.Api/Services/AuthService.cs index b3c26c8..6ab66ac 100644 --- a/src/Purrse.Api/Services/AuthService.cs +++ b/src/Purrse.Api/Services/AuthService.cs @@ -26,6 +26,8 @@ public class AuthService : IAuthService public async Task RegisterAsync(RegisterRequest request) { + ValidatePassword(request.Password); + if (await _db.Users.AnyAsync(u => u.Username == request.Username)) throw new InvalidOperationException("Username already exists"); if (await _db.Users.AnyAsync(u => u.Email == request.Email)) @@ -36,7 +38,7 @@ public class AuthService : IAuthService Id = Guid.NewGuid(), Username = request.Username, Email = request.Email, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password, workFactor: 12), CreatedAt = DateTime.UtcNow }; @@ -45,7 +47,7 @@ public class AuthService : IAuthService await _categoryService.SeedDefaultCategoriesAsync(user.Id); - return GenerateAuthResponse(user); + return await GenerateAuthResponseAsync(user); } public async Task LoginAsync(LoginRequest request) @@ -59,16 +61,17 @@ public class AuthService : IAuthService user.LastLoginAt = DateTime.UtcNow; await _db.SaveChangesAsync(); - return GenerateAuthResponse(user); + return await GenerateAuthResponseAsync(user); } public async Task RefreshTokenAsync(string refreshToken) { + var hashedToken = HashRefreshToken(refreshToken); var user = await _db.Users.FirstOrDefaultAsync(u => - u.RefreshToken == refreshToken && u.RefreshTokenExpiresAt > DateTime.UtcNow) + u.RefreshToken == hashedToken && u.RefreshTokenExpiresAt > DateTime.UtcNow) ?? throw new UnauthorizedAccessException("Invalid or expired refresh token"); - return GenerateAuthResponse(user); + return await GenerateAuthResponseAsync(user); } public async Task RevokeRefreshTokenAsync(Guid userId) @@ -117,7 +120,8 @@ public class AuthService : IAuthService if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash)) throw new InvalidOperationException("Current password is incorrect"); - user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + ValidatePassword(request.NewPassword); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword, workFactor: 12); await _db.SaveChangesAsync(); } @@ -130,15 +134,16 @@ public class AuthService : IAuthService await _db.SaveChangesAsync(); } - private AuthResponse GenerateAuthResponse(User user) + private async Task GenerateAuthResponseAsync(User user) { var expiresAt = DateTime.UtcNow.AddHours(12); var token = GenerateJwtToken(user, expiresAt); var refreshToken = GenerateRefreshToken(); - user.RefreshToken = refreshToken; + // Store only a hash of the refresh token so a DB breach doesn't expose reusable tokens + user.RefreshToken = HashRefreshToken(refreshToken); user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30); - _db.SaveChanges(); + await _db.SaveChangesAsync(); return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName); } @@ -170,4 +175,16 @@ public class AuthService : IAuthService var bytes = RandomNumberGenerator.GetBytes(64); return Convert.ToBase64String(bytes); } + + private static void ValidatePassword(string password) + { + if (string.IsNullOrEmpty(password) || password.Length < 8) + throw new InvalidOperationException("Password must be at least 8 characters long"); + } + + private static string HashRefreshToken(string refreshToken) + { + var bytes = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)); + return Convert.ToBase64String(bytes); + } } diff --git a/src/Purrse.Api/Services/BankSyncService.cs b/src/Purrse.Api/Services/BankSyncService.cs index f212454..0dc3469 100644 --- a/src/Purrse.Api/Services/BankSyncService.cs +++ b/src/Purrse.Api/Services/BankSyncService.cs @@ -283,9 +283,16 @@ public class BankSyncService : IBankSyncService _db.Accounts.Add(newAccount); linkedAccount.AccountId = newAccount.Id; } + else if (request.AccountId.HasValue) + { + // Verify the target account belongs to the authenticated user + var targetAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId.Value && a.UserId == userId) + ?? throw new KeyNotFoundException("Account not found"); + linkedAccount.AccountId = targetAccount.Id; + } else { - linkedAccount.AccountId = request.AccountId; + linkedAccount.AccountId = null; } await _db.SaveChangesAsync(); @@ -305,7 +312,17 @@ public class BankSyncService : IBankSyncService .FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId) ?? throw new KeyNotFoundException("Linked account not found"); - linkedAccount.AccountId = request.AccountId; + // Verify the target account belongs to the authenticated user + if (request.AccountId.HasValue) + { + var targetAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId.Value && a.UserId == userId) + ?? throw new KeyNotFoundException("Account not found"); + linkedAccount.AccountId = targetAccount.Id; + } + else + { + linkedAccount.AccountId = null; + } linkedAccount.IsEnabled = request.IsEnabled; await _db.SaveChangesAsync(); diff --git a/src/Purrse.Api/Services/ChatService.cs b/src/Purrse.Api/Services/ChatService.cs index b4de673..173f4f0 100644 --- a/src/Purrse.Api/Services/ChatService.cs +++ b/src/Purrse.Api/Services/ChatService.cs @@ -112,6 +112,7 @@ public class ChatService : IChatService // Load AI settings var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; + ValidateOllamaUrl(ollamaUrl); var modelName = settings?.ModelName ?? "llama3.1:8b"; var chatContextSize = settings?.ChatContextSize ?? 16384; var chatBotName = settings?.ChatBotName; @@ -1281,4 +1282,53 @@ public class ChatService : IChatService } return int.TryParse(value?.ToString(), out var result) ? result : null; } + + private static void ValidateOllamaUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new InvalidOperationException("Invalid Ollama URL"); + + if (uri.Scheme != "http" && uri.Scheme != "https") + throw new InvalidOperationException("Ollama URL must use http or https"); + + var host = uri.Host.ToLowerInvariant(); + + if (host == "localhost" || host == "host.docker.internal") + return; + + if (host == "metadata.google.internal") + throw new InvalidOperationException("Ollama URL points to a blocked address"); + + if (System.Net.IPAddress.TryParse(host, out var ip)) + { + if (ip.IsIPv4MappedToIPv6) + ip = ip.MapToIPv4(); + + if (!IsAllowedIp(ip)) + throw new InvalidOperationException("Ollama URL must point to a local or private network address"); + return; + } + + if (!host.Contains('.')) + return; + + throw new InvalidOperationException("Ollama URL must point to a local or private network address"); + } + + private static bool IsAllowedIp(System.Net.IPAddress ip) + { + if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return System.Net.IPAddress.IsLoopback(ip); + + var bytes = ip.GetAddressBytes(); + + if (bytes[0] == 0) return false; + if (bytes[0] == 169 && bytes[1] == 254) return false; + if (bytes[0] == 127) return true; + if (bytes[0] == 10) return true; + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + if (bytes[0] == 192 && bytes[1] == 168) return true; + + return false; + } } diff --git a/src/Purrse.Api/Services/EncryptionService.cs b/src/Purrse.Api/Services/EncryptionService.cs index dc7c68d..ecbb9d1 100644 --- a/src/Purrse.Api/Services/EncryptionService.cs +++ b/src/Purrse.Api/Services/EncryptionService.cs @@ -7,28 +7,44 @@ namespace Purrse.Api.Services; public class EncryptionService : IEncryptionService { private readonly byte[] _key; + private readonly byte[] _legacyKey; + + // Version prefix allows migrating cipher formats without breaking existing data + private const byte VersionAesGcm = 0x02; + + private const int GcmNonceSize = 12; + private const int GcmTagSize = 16; + private const int AesCbcIvSize = 16; public EncryptionService(IConfiguration configuration) { var keyString = configuration["BankSync:EncryptionKey"] ?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured"); - _key = SHA256.HashData(Encoding.UTF8.GetBytes(keyString)); + + // New key: HKDF-derived for AES-GCM + var inputKey = Encoding.UTF8.GetBytes(keyString); + _key = HKDF.DeriveKey(HashAlgorithmName.SHA256, inputKey, 32, info: Encoding.UTF8.GetBytes("purrse-encryption-v2")); + + // Legacy key: plain SHA256 for decrypting old AES-CBC data + _legacyKey = SHA256.HashData(inputKey); } public string Encrypt(string plainText) { - using var aes = Aes.Create(); - aes.Key = _key; - aes.GenerateIV(); - - using var encryptor = aes.CreateEncryptor(); var plainBytes = Encoding.UTF8.GetBytes(plainText); - var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + var nonce = RandomNumberGenerator.GetBytes(GcmNonceSize); + var cipherBytes = new byte[plainBytes.Length]; + var tag = new byte[GcmTagSize]; - // Prepend IV to ciphertext - var result = new byte[aes.IV.Length + cipherBytes.Length]; - Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length); - Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length); + using var aesGcm = new AesGcm(_key, GcmTagSize); + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + + // Format: [version(1)] [nonce(12)] [tag(16)] [ciphertext] + var result = new byte[1 + nonce.Length + tag.Length + cipherBytes.Length]; + result[0] = VersionAesGcm; + Buffer.BlockCopy(nonce, 0, result, 1, nonce.Length); + Buffer.BlockCopy(tag, 0, result, 1 + nonce.Length, tag.Length); + Buffer.BlockCopy(cipherBytes, 0, result, 1 + nonce.Length + tag.Length, cipherBytes.Length); return Convert.ToBase64String(result); } @@ -37,10 +53,38 @@ public class EncryptionService : IEncryptionService { var fullCipher = Convert.FromBase64String(cipherText); - using var aes = Aes.Create(); - aes.Key = _key; + // Version prefix identifies the cipher format + if (fullCipher.Length > 0 && fullCipher[0] == VersionAesGcm) + return DecryptAesGcm(fullCipher); - var iv = new byte[aes.BlockSize / 8]; + // No version prefix = legacy AES-CBC data + return DecryptLegacyAesCbc(fullCipher); + } + + private string DecryptAesGcm(byte[] fullCipher) + { + var nonce = new byte[GcmNonceSize]; + var tag = new byte[GcmTagSize]; + var cipherBytes = new byte[fullCipher.Length - 1 - GcmNonceSize - GcmTagSize]; + + Buffer.BlockCopy(fullCipher, 1, nonce, 0, GcmNonceSize); + Buffer.BlockCopy(fullCipher, 1 + GcmNonceSize, tag, 0, GcmTagSize); + Buffer.BlockCopy(fullCipher, 1 + GcmNonceSize + GcmTagSize, cipherBytes, 0, cipherBytes.Length); + + var plainBytes = new byte[cipherBytes.Length]; + + using var aesGcm = new AesGcm(_key, GcmTagSize); + aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); + } + + private string DecryptLegacyAesCbc(byte[] fullCipher) + { + using var aes = Aes.Create(); + aes.Key = _legacyKey; + + var iv = new byte[AesCbcIvSize]; var cipher = new byte[fullCipher.Length - iv.Length]; Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length); Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length); diff --git a/src/Purrse.Api/Services/FileWatcherService.cs b/src/Purrse.Api/Services/FileWatcherService.cs index bdf82b8..d0d3965 100644 --- a/src/Purrse.Api/Services/FileWatcherService.cs +++ b/src/Purrse.Api/Services/FileWatcherService.cs @@ -78,16 +78,37 @@ public class FileWatcherService : BackgroundService var importService = scope.ServiceProvider.GetRequiredService(); // 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) { diff --git a/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs b/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs index fd52edc..f27075f 100644 --- a/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs +++ b/src/Purrse.Api/Services/SimpleFinConnectionMigration.cs @@ -26,7 +26,7 @@ public static class SimpleFinConnectionMigration "CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)"); var alreadyApplied = await db.Database - .SqlQueryRaw($"SELECT 1 AS \"Value\" FROM data_migrations WHERE name = '{MigrationName}'") + .SqlQueryRaw("SELECT 1 AS \"Value\" FROM data_migrations WHERE name = {0}", MigrationName) .AnyAsync(); if (alreadyApplied) @@ -138,6 +138,6 @@ public static class SimpleFinConnectionMigration private static async Task MarkCompleteAsync(PurrseDbContext db) { await db.Database.ExecuteSqlRawAsync( - $"INSERT INTO data_migrations (name, applied_at) VALUES ('{MigrationName}', NOW()) ON CONFLICT DO NOTHING"); + "INSERT INTO data_migrations (name, applied_at) VALUES ({0}, NOW()) ON CONFLICT DO NOTHING", MigrationName); } } diff --git a/src/Purrse.Api/Services/TransactionService.cs b/src/Purrse.Api/Services/TransactionService.cs index c951fe5..47e507d 100644 --- a/src/Purrse.Api/Services/TransactionService.cs +++ b/src/Purrse.Api/Services/TransactionService.cs @@ -230,6 +230,8 @@ public class TransactionService : ITransactionService private async Task> PaginateAsync(IQueryable query, int page, int pageSize) { + pageSize = Math.Clamp(pageSize, 1, 200); + page = Math.Max(page, 1); var totalCount = await query.CountAsync(); var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); diff --git a/src/Purrse.Core/Interfaces/Services/IAccountService.cs b/src/Purrse.Core/Interfaces/Services/IAccountService.cs index 585f289..3359c05 100644 --- a/src/Purrse.Core/Interfaces/Services/IAccountService.cs +++ b/src/Purrse.Core/Interfaces/Services/IAccountService.cs @@ -10,5 +10,5 @@ public interface IAccountService Task UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request); Task DeleteAsync(Guid userId, Guid accountId); Task> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate); - Task RecalculateBalanceAsync(Guid accountId); + Task RecalculateBalanceAsync(Guid userId, Guid accountId); }