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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -553,7 +553,7 @@ async function fetchReport<T>(request: Promise<{ data: T }>): Promise<T | null>
|
||||
} 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ActionResult<ImportUploadResponse>> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ public class ReconciliationController : ControllerBase
|
||||
[HttpPost("{accountId}/complete")]
|
||||
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)
|
||||
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||
|
||||
@@ -80,6 +84,10 @@ public class ReconciliationController : ControllerBase
|
||||
[HttpPost("{accountId}/cancel")]
|
||||
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)
|
||||
?? throw new KeyNotFoundException("Reconciliation not found");
|
||||
|
||||
|
||||
@@ -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<RequestLoggingMiddleware>();
|
||||
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.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapControllers().RequireRateLimiting("api");
|
||||
app.MapHub<NotificationHub>("/hubs/notifications");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -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..];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ public class AuthService : IAuthService
|
||||
|
||||
public async Task<AuthResponse> 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<AuthResponse> 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<AuthResponse> 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<AuthResponse> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ public class TransactionService : ITransactionService
|
||||
|
||||
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 items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
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 DeleteAsync(Guid userId, Guid accountId);
|
||||
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
|
||||
Task RecalculateBalanceAsync(Guid accountId);
|
||||
Task RecalculateBalanceAsync(Guid userId, Guid accountId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user