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
+1 -1
View File
@@ -22,7 +22,7 @@ services:
dockerfile: src/Purrse.Api/Dockerfile dockerfile: src/Purrse.Api/Dockerfile
container_name: purrse-api container_name: purrse-api
environment: 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__Key=${JWT_KEY:-PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!}
- Jwt__Issuer=Purrse - Jwt__Issuer=Purrse
- Jwt__Audience=Purrse - Jwt__Audience=Purrse
+7
View File
@@ -4,6 +4,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+3 -3
View File
@@ -237,7 +237,7 @@ async function loadConversations() {
conversations.value = data conversations.value = data
conversationsLoaded.value = true conversationsLoaded.value = true
} catch (err) { } 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 messages.value = data.messages
await scrollToBottom() await scrollToBottom()
} catch (err) { } 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() startNewChat()
} }
} catch (err) { } catch (err) {
console.error('Failed to delete conversation', err) console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error')
} }
} }
+3 -3
View File
@@ -162,7 +162,7 @@ async function loadConversations() {
const { data } = await chatApi.getConversations() const { data } = await chatApi.getConversations()
conversations.value = data conversations.value = data
} catch (err) { } 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 messages.value = data.messages
await scrollToBottom() await scrollToBottom()
} catch (err) { } 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() startNewChat()
} }
} catch (err) { } catch (err) {
console.error('Failed to delete conversation', err) console.error('Failed to delete conversation:', err instanceof Error ? err.message : 'Unknown error')
} }
} }
+1 -1
View File
@@ -553,7 +553,7 @@ async function fetchReport<T>(request: Promise<{ data: T }>): Promise<T | null>
} catch (err: any) { } catch (err: any) {
const status = err?.response?.status const status = err?.response?.status
if (status && status >= 500) { if (status && status >= 500) {
console.error('Report request failed:', err) console.error('Report request failed:', err instanceof Error ? err.message : 'Unknown error')
return null return null
} }
// 4xx (including 404) treated as no data // 4xx (including 404) treated as no data
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Purrse.Core.DTOs; using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services; using Purrse.Core.Interfaces.Services;
using System.Security.Claims; using System.Security.Claims;
@@ -8,6 +9,7 @@ namespace Purrse.Api.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[EnableRateLimiting("auth")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IAuthService _authService; private readonly IAuthService _authService;
@@ -17,13 +17,15 @@ public class ImportsController : ControllerBase
public ImportsController(IImportService importService) => _importService = importService; public ImportsController(IImportService importService) => _importService = importService;
[HttpPost("upload")] [HttpPost("upload")]
[RequestSizeLimit(10_000_000)] // 10 MB
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file) public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
return BadRequest(new { error = "No file uploaded" }); return BadRequest(new { error = "No file uploaded" });
using var stream = file.OpenReadStream(); 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); return Ok(result);
} }
@@ -57,6 +57,10 @@ public class ReconciliationController : ControllerBase
[HttpPost("{accountId}/complete")] [HttpPost("{accountId}/complete")]
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId) public async Task<ActionResult> 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) var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
?? throw new KeyNotFoundException("Reconciliation not found"); ?? throw new KeyNotFoundException("Reconciliation not found");
@@ -80,6 +84,10 @@ public class ReconciliationController : ControllerBase
[HttpPost("{accountId}/cancel")] [HttpPost("{accountId}/cancel")]
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId) public async Task<ActionResult> 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) var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
?? throw new KeyNotFoundException("Reconciliation not found"); ?? throw new KeyNotFoundException("Reconciliation not found");
+60 -2
View File
@@ -1,6 +1,8 @@
using System.Text; using System.Text;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Purrse.Api.Hubs; using Purrse.Api.Hubs;
@@ -33,7 +35,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse", ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "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 // SignalR JWT support
@@ -114,6 +117,48 @@ builder.Services.AddControllers()
}); });
builder.Services.AddEndpointsApiExplorer(); 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(); var app = builder.Build();
// Auto-migrate database // Auto-migrate database
@@ -151,11 +196,24 @@ await pluginService.DiscoverAndLoadPluginsAsync();
app.UseMiddleware<RequestLoggingMiddleware>(); app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>(); app.UseMiddleware<ExceptionHandlingMiddleware>();
// 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.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers(); app.MapControllers().RequireRateLimiting("api");
app.MapHub<NotificationHub>("/hubs/notifications"); app.MapHub<NotificationHub>("/hubs/notifications");
app.Run(); app.Run();
+10 -3
View File
@@ -114,9 +114,9 @@ public class AccountService : IAccountService
return entries; 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; if (account == null) return;
var sum = await _db.Transactions var sum = await _db.Transactions
@@ -129,9 +129,16 @@ public class AccountService : IAccountService
} }
private static AccountResponse MapToResponse(Account a) => new( 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.Balance, a.CreditLimit, a.InterestRate,
a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt, a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt,
a.LoanDetail != null 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..];
}
} }
@@ -92,6 +92,7 @@ public class AiCategorizationService : IAiCategorizationService
} }
settings.IsEnabled = request.IsEnabled; settings.IsEnabled = request.IsEnabled;
ValidateOllamaUrl(request.OllamaUrl);
settings.OllamaUrl = request.OllamaUrl; settings.OllamaUrl = request.OllamaUrl;
settings.ModelName = request.ModelName; settings.ModelName = request.ModelName;
settings.ConfidenceThreshold = request.ConfidenceThreshold; settings.ConfidenceThreshold = request.ConfidenceThreshold;
@@ -521,4 +522,63 @@ public class AiCategorizationService : IAiCategorizationService
return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified); 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;
}
} }
+26 -9
View File
@@ -26,6 +26,8 @@ public class AuthService : IAuthService
public async Task<AuthResponse> RegisterAsync(RegisterRequest request) public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
{ {
ValidatePassword(request.Password);
if (await _db.Users.AnyAsync(u => u.Username == request.Username)) if (await _db.Users.AnyAsync(u => u.Username == request.Username))
throw new InvalidOperationException("Username already exists"); throw new InvalidOperationException("Username already exists");
if (await _db.Users.AnyAsync(u => u.Email == request.Email)) if (await _db.Users.AnyAsync(u => u.Email == request.Email))
@@ -36,7 +38,7 @@ public class AuthService : IAuthService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = request.Username, Username = request.Username,
Email = request.Email, Email = request.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password, workFactor: 12),
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -45,7 +47,7 @@ public class AuthService : IAuthService
await _categoryService.SeedDefaultCategoriesAsync(user.Id); await _categoryService.SeedDefaultCategoriesAsync(user.Id);
return GenerateAuthResponse(user); return await GenerateAuthResponseAsync(user);
} }
public async Task<AuthResponse> LoginAsync(LoginRequest request) public async Task<AuthResponse> LoginAsync(LoginRequest request)
@@ -59,16 +61,17 @@ public class AuthService : IAuthService
user.LastLoginAt = DateTime.UtcNow; user.LastLoginAt = DateTime.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return GenerateAuthResponse(user); return await GenerateAuthResponseAsync(user);
} }
public async Task<AuthResponse> RefreshTokenAsync(string refreshToken) public async Task<AuthResponse> RefreshTokenAsync(string refreshToken)
{ {
var hashedToken = HashRefreshToken(refreshToken);
var user = await _db.Users.FirstOrDefaultAsync(u => 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"); ?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
return GenerateAuthResponse(user); return await GenerateAuthResponseAsync(user);
} }
public async Task RevokeRefreshTokenAsync(Guid userId) public async Task RevokeRefreshTokenAsync(Guid userId)
@@ -117,7 +120,8 @@ public class AuthService : IAuthService
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash)) if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
throw new InvalidOperationException("Current password is incorrect"); 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(); await _db.SaveChangesAsync();
} }
@@ -130,15 +134,16 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
private AuthResponse GenerateAuthResponse(User user) private async Task<AuthResponse> GenerateAuthResponseAsync(User user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(12); var expiresAt = DateTime.UtcNow.AddHours(12);
var token = GenerateJwtToken(user, expiresAt); var token = GenerateJwtToken(user, expiresAt);
var refreshToken = GenerateRefreshToken(); 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); user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
_db.SaveChanges(); await _db.SaveChangesAsync();
return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName); return new AuthResponse(token, refreshToken, expiresAt, user.Username, user.DisplayName);
} }
@@ -170,4 +175,16 @@ public class AuthService : IAuthService
var bytes = RandomNumberGenerator.GetBytes(64); var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes); 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);
}
} }
+19 -2
View File
@@ -283,9 +283,16 @@ public class BankSyncService : IBankSyncService
_db.Accounts.Add(newAccount); _db.Accounts.Add(newAccount);
linkedAccount.AccountId = newAccount.Id; 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 else
{ {
linkedAccount.AccountId = request.AccountId; linkedAccount.AccountId = null;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -305,7 +312,17 @@ public class BankSyncService : IBankSyncService
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId) .FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
?? throw new KeyNotFoundException("Linked account not found"); ?? 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; linkedAccount.IsEnabled = request.IsEnabled;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
+50
View File
@@ -112,6 +112,7 @@ public class ChatService : IChatService
// Load AI settings // Load AI settings
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
ValidateOllamaUrl(ollamaUrl);
var modelName = settings?.ModelName ?? "llama3.1:8b"; var modelName = settings?.ModelName ?? "llama3.1:8b";
var chatContextSize = settings?.ChatContextSize ?? 16384; var chatContextSize = settings?.ChatContextSize ?? 16384;
var chatBotName = settings?.ChatBotName; var chatBotName = settings?.ChatBotName;
@@ -1281,4 +1282,53 @@ public class ChatService : IChatService
} }
return int.TryParse(value?.ToString(), out var result) ? result : null; 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;
}
} }
+58 -14
View File
@@ -7,28 +7,44 @@ namespace Purrse.Api.Services;
public class EncryptionService : IEncryptionService public class EncryptionService : IEncryptionService
{ {
private readonly byte[] _key; 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) public EncryptionService(IConfiguration configuration)
{ {
var keyString = configuration["BankSync:EncryptionKey"] var keyString = configuration["BankSync:EncryptionKey"]
?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured"); ?? 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) 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 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 using var aesGcm = new AesGcm(_key, GcmTagSize);
var result = new byte[aes.IV.Length + cipherBytes.Length]; aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length); // 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); return Convert.ToBase64String(result);
} }
@@ -37,10 +53,38 @@ public class EncryptionService : IEncryptionService
{ {
var fullCipher = Convert.FromBase64String(cipherText); var fullCipher = Convert.FromBase64String(cipherText);
using var aes = Aes.Create(); // Version prefix identifies the cipher format
aes.Key = _key; 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]; var cipher = new byte[fullCipher.Length - iv.Length];
Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length); Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length);
Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length); Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length);
+26 -5
View File
@@ -78,16 +78,37 @@ public class FileWatcherService : BackgroundService
var importService = scope.ServiceProvider.GetRequiredService<IImportService>(); var importService = scope.ServiceProvider.GetRequiredService<IImportService>();
// Find the target account by matching filename to account name // 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 fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name);
var account = await db.Accounts string? targetUsername = null;
.Where(a => a.IsActive && !a.IsClosed) string accountMatch = fileNameWithoutExt!;
.FirstOrDefaultAsync(a => a.Name.ToLower() == fileNameWithoutExt!.ToLower());
// Fall back to first active account if (fileNameWithoutExt!.Contains("--"))
account ??= await db.Accounts {
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) .Where(a => a.IsActive && !a.IsClosed)
.OrderBy(a => a.SortOrder) .OrderBy(a => a.SortOrder)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
}
if (account == null) if (account == null)
{ {
@@ -26,7 +26,7 @@ public static class SimpleFinConnectionMigration
"CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)"); "CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)");
var alreadyApplied = await db.Database var alreadyApplied = await db.Database
.SqlQueryRaw<int>($"SELECT 1 AS \"Value\" FROM data_migrations WHERE name = '{MigrationName}'") .SqlQueryRaw<int>("SELECT 1 AS \"Value\" FROM data_migrations WHERE name = {0}", MigrationName)
.AnyAsync(); .AnyAsync();
if (alreadyApplied) if (alreadyApplied)
@@ -138,6 +138,6 @@ public static class SimpleFinConnectionMigration
private static async Task MarkCompleteAsync(PurrseDbContext db) private static async Task MarkCompleteAsync(PurrseDbContext db)
{ {
await db.Database.ExecuteSqlRawAsync( 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);
} }
} }
@@ -230,6 +230,8 @@ public class TransactionService : ITransactionService
private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize) private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize)
{ {
pageSize = Math.Clamp(pageSize, 1, 200);
page = Math.Max(page, 1);
var totalCount = await query.CountAsync(); var totalCount = await query.CountAsync();
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
@@ -10,5 +10,5 @@ public interface IAccountService
Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request); Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request);
Task DeleteAsync(Guid userId, Guid accountId); Task DeleteAsync(Guid userId, Guid accountId);
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate); Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
Task RecalculateBalanceAsync(Guid accountId); Task RecalculateBalanceAsync(Guid userId, Guid accountId);
} }