Compare commits
2 Commits
71c293320b
...
595076033b
| Author | SHA1 | Date | |
|---|---|---|---|
| 595076033b | |||
| 0c874575d4 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -64,11 +64,21 @@ 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))
|
||||||
|
{
|
||||||
|
// Limit data size to 10MB to prevent DoS
|
||||||
|
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);
|
_logger.LogWarning("DataInbox: Invalid data format - not base64 encoded for source {Source}", dataInbox.Source);
|
||||||
return BadRequest("Invalid data format - not base64 encoded");
|
return BadRequest("Invalid data format - not base64 encoded");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataInbox.Id = Guid.NewGuid();
|
dataInbox.Id = Guid.NewGuid();
|
||||||
dataInbox.CreatedAt = DateTime.UtcNow;
|
dataInbox.CreatedAt = DateTime.UtcNow;
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace DiunaBI.API.Hubs;
|
namespace DiunaBI.API.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for broadcasting entity change notifications to authenticated clients.
|
||||||
|
/// Clients can only listen - broadcasting is done server-side by EntityChangeInterceptor.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
public class EntityChangeHub : Hub
|
public class EntityChangeHub : Hub
|
||||||
{
|
{
|
||||||
public async Task SendEntityChange(string module, string id, string operation)
|
// No public methods - clients can only listen for "EntityChanged" events
|
||||||
{
|
// Broadcasting is handled server-side by EntityChangeInterceptor via IHubContext
|
||||||
await Clients.All.SendAsync("EntityChanged", new
|
|
||||||
{
|
|
||||||
module,
|
|
||||||
id,
|
|
||||||
operation
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -37,7 +39,12 @@ builder.Services.AddSingleton<EntityChangeInterceptor>();
|
|||||||
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
|
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
|
||||||
{
|
{
|
||||||
options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure"));
|
options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure"));
|
||||||
|
|
||||||
|
// Only log SQL parameters in development (may contain sensitive data)
|
||||||
|
if (builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
options.EnableSensitiveDataLogging();
|
options.EnableSensitiveDataLogging();
|
||||||
|
}
|
||||||
|
|
||||||
// Add EntityChangeInterceptor
|
// Add EntityChangeInterceptor
|
||||||
var interceptor = serviceProvider.GetRequiredService<EntityChangeInterceptor>();
|
var interceptor = serviceProvider.GetRequiredService<EntityChangeInterceptor>();
|
||||||
@@ -67,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();
|
||||||
|
|
||||||
@@ -197,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();
|
||||||
|
|
||||||
@@ -254,8 +308,8 @@ app.Use(async (context, next) =>
|
|||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
// SignalR Hub
|
// SignalR Hub - Requires JWT authentication
|
||||||
app.MapHub<EntityChangeHub>("/hubs/entitychanges");
|
app.MapHub<EntityChangeHub>("/hubs/entitychanges").RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow }))
|
app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow }))
|
||||||
.AllowAnonymous();
|
.AllowAnonymous();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user