Compare commits
2 Commits
71c293320b
...
595076033b
| Author | SHA1 | Date | |
|---|---|---|---|
| 595076033b | |||
| 0c874575d4 |
@@ -5,6 +5,21 @@
|
||||
|
||||
## 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):**
|
||||
- ✅ Removed Seq logging sink to eliminate commercial licensing concerns
|
||||
- ✅ Removed `Serilog.Sinks.Seq` NuGet package from DiunaBI.API.csproj
|
||||
|
||||
@@ -2,6 +2,7 @@ using DiunaBI.API.Services;
|
||||
using DiunaBI.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DiunaBI.API.Controllers;
|
||||
|
||||
@@ -15,6 +16,7 @@ public class AuthController(
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpPost("apiToken")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public async Task<IActionResult> ApiToken([FromBody] string idToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -64,10 +64,20 @@ public class DataInboxController : Controller
|
||||
}
|
||||
|
||||
// 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);
|
||||
return BadRequest("Invalid data format - not base64 encoded");
|
||||
// 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);
|
||||
return BadRequest("Invalid data format - not base64 encoded");
|
||||
}
|
||||
}
|
||||
|
||||
dataInbox.Id = Guid.NewGuid();
|
||||
@@ -97,6 +107,16 @@ public class DataInboxController : Controller
|
||||
{
|
||||
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();
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
|
||||
@@ -42,6 +42,16 @@ public class JobsController : Controller
|
||||
{
|
||||
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();
|
||||
|
||||
if (status.HasValue)
|
||||
|
||||
@@ -48,6 +48,16 @@ public class LayersController : Controller
|
||||
{
|
||||
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);
|
||||
|
||||
if (name != null)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
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 async Task SendEntityChange(string module, string id, string operation)
|
||||
{
|
||||
await Clients.All.SendAsync("EntityChanged", new
|
||||
{
|
||||
module,
|
||||
id,
|
||||
operation
|
||||
});
|
||||
}
|
||||
// No public methods - clients can only listen for "EntityChanged" events
|
||||
// Broadcasting is handled server-side by EntityChangeInterceptor via IHubContext
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using DiunaBI.API.Hubs;
|
||||
using DiunaBI.API.Services;
|
||||
using DiunaBI.Infrastructure.Data;
|
||||
@@ -37,7 +39,12 @@ builder.Services.AddSingleton<EntityChangeInterceptor>();
|
||||
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
options.UseSqlServer(connectionString, sqlOptions => sqlOptions.MigrationsAssembly("DiunaBI.Infrastructure"));
|
||||
options.EnableSensitiveDataLogging();
|
||||
|
||||
// Only log SQL parameters in development (may contain sensitive data)
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
options.EnableSensitiveDataLogging();
|
||||
}
|
||||
|
||||
// Add EntityChangeInterceptor
|
||||
var interceptor = serviceProvider.GetRequiredService<EntityChangeInterceptor>();
|
||||
@@ -67,6 +74,41 @@ builder.Services.AddCors(options =>
|
||||
|
||||
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
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
@@ -197,6 +239,18 @@ pluginManager.LoadPluginsFromDirectory(pluginsPath);
|
||||
|
||||
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.UseAuthorization();
|
||||
|
||||
@@ -254,8 +308,8 @@ app.Use(async (context, next) =>
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<EntityChangeHub>("/hubs/entitychanges");
|
||||
// SignalR Hub - Requires JWT authentication
|
||||
app.MapHub<EntityChangeHub>("/hubs/entitychanges").RequireAuthorization();
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow }))
|
||||
.AllowAnonymous();
|
||||
|
||||
@@ -36,7 +36,7 @@ public class GoogleAuthService(AppDbContext context, IConfiguration configuratio
|
||||
if (user == null)
|
||||
{
|
||||
_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;
|
||||
|
||||
@@ -54,7 +54,8 @@ public static class ServiceCollectionExtensions
|
||||
// For singleton, we can't inject scoped services directly
|
||||
// We'll get them from the service provider when needed
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class EntityChangeHubService : IAsyncDisposable
|
||||
{
|
||||
private readonly string _hubUrl;
|
||||
private readonly ILogger<EntityChangeHubService> _logger;
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
private HubConnection? _hubConnection;
|
||||
private bool _isInitialized;
|
||||
private readonly SemaphoreSlim _initializationLock = new SemaphoreSlim(1, 1);
|
||||
@@ -19,7 +20,8 @@ public class EntityChangeHubService : IAsyncDisposable
|
||||
public EntityChangeHubService(
|
||||
string apiBaseUrl,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<EntityChangeHubService> logger)
|
||||
ILogger<EntityChangeHubService> logger,
|
||||
TokenProvider tokenProvider)
|
||||
{
|
||||
_instanceId = Interlocked.Increment(ref _instanceCounter);
|
||||
|
||||
@@ -28,6 +30,7 @@ public class EntityChangeHubService : IAsyncDisposable
|
||||
_hubUrl = baseUrl + "/hubs/entitychanges";
|
||||
|
||||
_logger = logger;
|
||||
_tokenProvider = tokenProvider;
|
||||
_logger.LogInformation("🏗️ EntityChangeHubService instance #{InstanceId} created. Hub URL: {HubUrl}", _instanceId, _hubUrl);
|
||||
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}");
|
||||
|
||||
_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()
|
||||
.Build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user