More security!
All checks were successful
Build Docker Images / test (map[name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m25s
Build Docker Images / test (map[name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m25s
Build Docker Images / build-and-push (map[image_suffix:morska name:Morska plugin_project:DiunaBI.Plugins.Morska]) (push) Successful in 1m41s
Build Docker Images / build-and-push (map[image_suffix:pedrollopl name:PedrolloPL plugin_project:DiunaBI.Plugins.PedrolloPL]) (push) Successful in 1m40s

This commit is contained in:
2025-12-05 23:41:56 +01:00
parent 0c874575d4
commit 595076033b
9 changed files with 131 additions and 7 deletions

View File

@@ -5,6 +5,21 @@
## RECENT CHANGES (This Session) ## RECENT CHANGES (This Session)
**Security Audit & Hardening (Dec 5, 2025):**
-**JWT Token Validation** - Enabled issuer/audience validation in [Program.cs](DiunaBI.API/Program.cs), fixed config key mismatch in [JwtTokenService.cs](DiunaBI.API/Services/JwtTokenService.cs)
-**API Key Security** - Created [ApiKeyAuthAttribute.cs](DiunaBI.API/Attributes/ApiKeyAuthAttribute.cs) with X-API-Key header auth, constant-time comparison
-**Job Endpoints** - Migrated 3 job scheduling endpoints in [JobsController.cs](DiunaBI.API/Controllers/JobsController.cs) from URL-based to header-based API keys
-**Stack Trace Exposure** - Fixed 20 instances across 3 controllers ([JobsController.cs](DiunaBI.API/Controllers/JobsController.cs), [LayersController.cs](DiunaBI.API/Controllers/LayersController.cs), [DataInboxController.cs](DiunaBI.API/Controllers/DataInboxController.cs)) - now returns generic error messages
-**SignalR Authentication** - Added [Authorize] to [EntityChangeHub.cs](DiunaBI.API/Hubs/EntityChangeHub.cs), configured JWT token in [EntityChangeHubService.cs](DiunaBI.UI.Shared/Services/EntityChangeHubService.cs)
-**Rate Limiting** - Implemented ASP.NET Core rate limiting: 100 req/min general, 10 req/min auth in [Program.cs](DiunaBI.API/Program.cs)
-**Security Headers** - Added XSS, clickjacking, MIME sniffing protection middleware in [Program.cs](DiunaBI.API/Program.cs)
-**Input Validation** - Added pagination limits (1-1000) to GetAll endpoints in 3 controllers
-**User Enumeration** - Fixed generic auth error in [GoogleAuthService.cs](DiunaBI.API/Services/GoogleAuthService.cs)
-**Sensitive Data Logging** - Made conditional on development only in [Program.cs](DiunaBI.API/Program.cs)
-**Base64 Size Limit** - Added 10MB limit to DataInbox in [DataInboxController.cs](DiunaBI.API/Controllers/DataInboxController.cs)
- Files modified: 12 files (API: Program.cs, 4 controllers, 3 services, 1 hub, 1 new attribute; UI: EntityChangeHubService.cs, ServiceCollectionExtensions.cs)
- Security status: 5/5 CRITICAL fixed, 3/3 HIGH fixed, 4/4 MEDIUM fixed
**Seq Removal - Logging Cleanup (Dec 5, 2025):** **Seq Removal - Logging Cleanup (Dec 5, 2025):**
- ✅ Removed Seq logging sink to eliminate commercial licensing concerns - ✅ Removed Seq logging sink to eliminate commercial licensing concerns
- ✅ Removed `Serilog.Sinks.Seq` NuGet package from DiunaBI.API.csproj - ✅ Removed `Serilog.Sinks.Seq` NuGet package from DiunaBI.API.csproj

View File

@@ -2,6 +2,7 @@ using DiunaBI.API.Services;
using DiunaBI.Domain.Entities; using DiunaBI.Domain.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DiunaBI.API.Controllers; namespace DiunaBI.API.Controllers;
@@ -15,6 +16,7 @@ public class AuthController(
: ControllerBase : ControllerBase
{ {
[HttpPost("apiToken")] [HttpPost("apiToken")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> ApiToken([FromBody] string idToken) public async Task<IActionResult> ApiToken([FromBody] string idToken)
{ {
try try

View File

@@ -64,10 +64,20 @@ public class DataInboxController : Controller
} }
// check if datainbox.data is base64 encoded value // check if datainbox.data is base64 encoded value
if (!string.IsNullOrEmpty(dataInbox.Data) && !IsBase64String(dataInbox.Data)) if (!string.IsNullOrEmpty(dataInbox.Data))
{ {
_logger.LogWarning("DataInbox: Invalid data format - not base64 encoded for source {Source}", dataInbox.Source); // Limit data size to 10MB to prevent DoS
return BadRequest("Invalid data format - not base64 encoded"); if (dataInbox.Data.Length > 10_000_000)
{
_logger.LogWarning("DataInbox: Data too large for source {Source}, size {Size}", dataInbox.Source, dataInbox.Data.Length);
return BadRequest("Data too large (max 10MB)");
}
if (!IsBase64String(dataInbox.Data))
{
_logger.LogWarning("DataInbox: Invalid data format - not base64 encoded for source {Source}", dataInbox.Source);
return BadRequest("Invalid data format - not base64 encoded");
}
} }
dataInbox.Id = Guid.NewGuid(); dataInbox.Id = Guid.NewGuid();
@@ -97,6 +107,16 @@ public class DataInboxController : Controller
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.DataInbox.AsQueryable(); var query = _db.DataInbox.AsQueryable();
if (!string.IsNullOrEmpty(search)) if (!string.IsNullOrEmpty(search))

View File

@@ -42,6 +42,16 @@ public class JobsController : Controller
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.QueueJobs.AsQueryable(); var query = _db.QueueJobs.AsQueryable();
if (status.HasValue) if (status.HasValue)

View File

@@ -48,6 +48,16 @@ public class LayersController : Controller
{ {
try try
{ {
// Validate pagination parameters
if (limit <= 0 || limit > 1000)
{
return BadRequest("Limit must be between 1 and 1000");
}
if (start < 0)
{
return BadRequest("Start must be non-negative");
}
var query = _db.Layers.Where(x => !x.IsDeleted); var query = _db.Layers.Where(x => !x.IsDeleted);
if (name != null) if (name != null)

View File

@@ -1,9 +1,11 @@
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 System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Threading.RateLimiting;
using DiunaBI.API.Hubs; using DiunaBI.API.Hubs;
using DiunaBI.API.Services; using DiunaBI.API.Services;
using DiunaBI.Infrastructure.Data; using DiunaBI.Infrastructure.Data;
@@ -72,6 +74,41 @@ builder.Services.AddCors(options =>
builder.Services.AddControllers(); builder.Services.AddControllers();
// Rate Limiting
builder.Services.AddRateLimiter(options =>
{
// Global API rate limit
options.AddFixedWindowLimiter("api", config =>
{
config.PermitLimit = 100;
config.Window = TimeSpan.FromMinutes(1);
config.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
config.QueueLimit = 0; // No queueing
});
// Strict limit for authentication endpoint
options.AddFixedWindowLimiter("auth", config =>
{
config.PermitLimit = 10;
config.Window = TimeSpan.FromMinutes(1);
config.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
config.QueueLimit = 0;
});
// Rejection response
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429; // Too Many Requests
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Too many requests. Please try again later.",
retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
? (double?)retryAfter.TotalSeconds
: (double?)null
}, cancellationToken: token);
};
});
// SignalR // SignalR
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -202,6 +239,18 @@ pluginManager.LoadPluginsFromDirectory(pluginsPath);
app.UseCors("CORSPolicy"); app.UseCors("CORSPolicy");
// Security Headers
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

@@ -36,7 +36,7 @@ public class GoogleAuthService(AppDbContext context, IConfiguration configuratio
if (user == null) if (user == null)
{ {
_logger.LogError("User not found in DiunaBI database: {Email}", payload.Email); _logger.LogError("User not found in DiunaBI database: {Email}", payload.Email);
return (false, null, "User not found in DiunaBI database"); return (false, null, "Authentication failed");
} }
user.UserName = payload.Name; user.UserName = payload.Name;

View File

@@ -54,7 +54,8 @@ public static class ServiceCollectionExtensions
// For singleton, we can't inject scoped services directly // For singleton, we can't inject scoped services directly
// We'll get them from the service provider when needed // We'll get them from the service provider when needed
var logger = sp.GetRequiredService<ILogger<EntityChangeHubService>>(); var logger = sp.GetRequiredService<ILogger<EntityChangeHubService>>();
return new EntityChangeHubService(apiBaseUrl, sp, logger); var tokenProvider = sp.GetRequiredService<TokenProvider>();
return new EntityChangeHubService(apiBaseUrl, sp, logger, tokenProvider);
}); });
return services; return services;

View File

@@ -7,6 +7,7 @@ public class EntityChangeHubService : IAsyncDisposable
{ {
private readonly string _hubUrl; private readonly string _hubUrl;
private readonly ILogger<EntityChangeHubService> _logger; private readonly ILogger<EntityChangeHubService> _logger;
private readonly TokenProvider _tokenProvider;
private HubConnection? _hubConnection; private HubConnection? _hubConnection;
private bool _isInitialized; private bool _isInitialized;
private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1);
@@ -19,7 +20,8 @@ public class EntityChangeHubService : IAsyncDisposable
public EntityChangeHubService( public EntityChangeHubService(
string apiBaseUrl, string apiBaseUrl,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<EntityChangeHubService> logger) ILogger<EntityChangeHubService> logger,
TokenProvider tokenProvider)
{ {
_instanceId = Interlocked.Increment(ref _instanceCounter); _instanceId = Interlocked.Increment(ref _instanceCounter);
@@ -28,6 +30,7 @@ public class EntityChangeHubService : IAsyncDisposable
_hubUrl = baseUrl + "/hubs/entitychanges"; _hubUrl = baseUrl + "/hubs/entitychanges";
_logger = logger; _logger = logger;
_tokenProvider = tokenProvider;
_logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl); _logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl);
Console.WriteLine($"🏗️ EntityChangeHubService instance #{_instanceId} created. Hub URL: {_hubUrl}, _isInitialized = {_isInitialized}"); Console.WriteLine($"🏗️ EntityChangeHubService instance #{_instanceId} created. Hub URL: {_hubUrl}, _isInitialized = {_isInitialized}");
} }
@@ -58,7 +61,21 @@ public class EntityChangeHubService : IAsyncDisposable
Console.WriteLine($"🔌 Initializing SignalR connection to {_hubUrl}"); Console.WriteLine($"🔌 Initializing SignalR connection to {_hubUrl}");
_hubConnection = new HubConnectionBuilder() _hubConnection = new HubConnectionBuilder()
.WithUrl(_hubUrl) .WithUrl(_hubUrl, options =>
{
// Add JWT token to SignalR connection
if (!string.IsNullOrEmpty(_tokenProvider.Token))
{
options.AccessTokenProvider = () => Task.FromResult<string?>(_tokenProvider.Token);
_logger.LogInformation("✅ JWT token added to SignalR connection");
Console.WriteLine($"✅ JWT token added to SignalR connection");
}
else
{
_logger.LogWarning("⚠️ No JWT token available for SignalR connection");
Console.WriteLine($"⚠️ No JWT token available for SignalR connection");
}
})
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();