Makes the application say what it is doing and when it fails
U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot silence browser errors, Umami on the admin SPA, and six security events that alert rules can actually be built on. The correlation id is the W3C trace id from the ambient Activity, enabled by one line of ActivityTrackingOptions so every entry from every category carries it without touching a call site. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the browser. The security events use source-generated LoggerMessage with constant templates. Sentry groups log events by message, so interpolating an email address would give every address its own issue and "more than 20 failed logins in five minutes" could never fire — the events would arrive, be visible, be tagged, and the alerting would silently be impossible. A test asserts the rendered message is identical across argument values. Scrubbing happens in-process, before transmission, and covers Set-Cookie as well as Cookie: the login response issues the refreshToken there, so scrubbing only the request side would protect nothing. Transactions are scrubbed too, because they carry request data and are the channel nobody thinks of. The tunnel derives its destination from the DSN once at startup and reads nothing from the request, which is what separates a tunnel from a server-side request forgery primitive. Size is capped by a bounded read rather than by trusting Content-Length, and the endpoint is rate limited. Two things found along the way. Zod 4's url() hands the value to the URL constructor, which accepts any scheme — so the existing frontend validation would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA would have called a nonexistent origin. Now constrained to http(s). And the new appsettings comments are verified against the real configuration provider, because the failure mode if it rejected them is both hosts refusing to start after a release switch. One deviation. IAdminTokenValidator was meant to gain a reason-reporting overload; implemented that way, a substitute returning false by default silently inverted the access decision while both methods compiled. Two methods whose difference is invisible at the call site is the defect, so it is now a single Validate returning AdminTokenResult. Touches two files from already-committed units: DatabaseMigrationExtensions (U2) gains a flush before the rethrow, or the one Critical event in the system dies with the process; AdminTokenValidator (U1) classifies why a bypass was refused. Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests, up from 213. tsc clean, eslint clean on every changed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Health;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
@@ -8,6 +9,10 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Load local developer overrides
|
||||
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// Logging first, then Sentry — same order and same reasons as the master host.
|
||||
builder.Logging.AddCmsLogging(builder.Environment);
|
||||
builder.WebHost.UseCmsSentry(builder.Configuration);
|
||||
|
||||
// 1. Initialize Module Orchestrator
|
||||
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
|
||||
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>());
|
||||
@@ -23,6 +28,7 @@ builder.Services.AddCmsHealthChecks();
|
||||
// and it will be reached directly during diagnosis. There is no reason for it to be the one
|
||||
// host without nosniff and HSTS. The path rules that do not apply here simply never match.
|
||||
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
|
||||
builder.Services.AddCmsObservability(builder.Configuration);
|
||||
|
||||
// Registered BEFORE module services — see the note in DataProtectionExtensions.
|
||||
builder.Services.AddCmsDataProtection();
|
||||
@@ -79,4 +85,8 @@ app.MapControllers();
|
||||
// API instance looks like, so it behaves like one in every other respect.
|
||||
app.MapCmsHealthChecks();
|
||||
|
||||
// This host serves no SPA, so nothing here posts envelopes today. The endpoint is mapped anyway
|
||||
// so both hosts behave identically and a slave that later serves an admin UI needs no change.
|
||||
app.MapSentryTunnel();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
// Pinned at Warning deliberately. At Information, EF prints every SQL statement INCLUDING
|
||||
// parameter values, and the login path passes a normalised email address through it.
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
@@ -36,6 +39,10 @@
|
||||
"Refresh": {
|
||||
"PermitLimit": 20,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"SentryTunnel": {
|
||||
"PermitLimit": 60,
|
||||
"WindowSeconds": 60
|
||||
}
|
||||
},
|
||||
"SecurityHeaders": {
|
||||
@@ -47,5 +54,16 @@
|
||||
],
|
||||
"AllowedScriptOrigins": [],
|
||||
"AllowedConnectOrigins": []
|
||||
},
|
||||
"Observability": {
|
||||
// Supplied per environment as Observability__SentryDsn. Empty means Sentry is skipped
|
||||
// entirely and console logging continues — a normal, supported state, not an error.
|
||||
"SentryDsn": "",
|
||||
// Falls back to ASPNETCORE_ENVIRONMENT when empty.
|
||||
"Environment": "",
|
||||
// Sentry's free plan counts transactions against the same quota as errors, and this setup's
|
||||
// value is in errors rather than performance traces.
|
||||
"TracesSampleRate": 0.1,
|
||||
"TunnelMaxPayloadBytes": 204800
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using SlpModularCms.Api.Extensions;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Health;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
@@ -9,6 +10,15 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Load local developer overrides
|
||||
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// Logging FIRST, so a problem initialising Sentry below is itself logged. Puts the W3C trace id
|
||||
// into the scope of every entry from every category — the correlation id that also travels to
|
||||
// the slave via traceparent and appears as `traceId` in ProblemDetails responses.
|
||||
builder.Logging.AddCmsLogging(builder.Environment);
|
||||
|
||||
// Then Sentry. Does nothing at all when no DSN is configured, which is a normal, fully
|
||||
// supported state rather than an error.
|
||||
builder.WebHost.UseCmsSentry(builder.Configuration);
|
||||
|
||||
// 1. Initialize Module Orchestrator
|
||||
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
|
||||
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>());
|
||||
@@ -20,6 +30,7 @@ builder.Services.AddCmsCors(builder.Configuration);
|
||||
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||
builder.Services.AddCmsHealthChecks();
|
||||
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
|
||||
builder.Services.AddCmsObservability(builder.Configuration);
|
||||
|
||||
// Registered BEFORE module services: modules must not configure Data Protection themselves,
|
||||
// because a later registration would override this persistent key store (see
|
||||
@@ -93,6 +104,13 @@ app.MapControllers();
|
||||
// (/api/v1/System/capabilities). Those are CMS domain state and must not be used for monitoring.
|
||||
app.MapCmsHealthChecks();
|
||||
|
||||
// Forwards browser Sentry envelopes through this origin, because ad blockers block requests to
|
||||
// Sentry domains outright. Mapped before the SPA catch-all below, and deliberately NOT on the
|
||||
// availability gate's bypass list: if the instance is switched off, losing admin-SPA error
|
||||
// reports is acceptable, and that is one fewer anonymous outbound-capable endpoint reachable on
|
||||
// a disabled instance.
|
||||
app.MapSentryTunnel();
|
||||
|
||||
// SPA fallbacks so client-side routes (e.g. /admin/dashboard) resolve to the right index.html
|
||||
// instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s.
|
||||
app.MapCmsSpaFallbacks();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
// Pinned at Warning deliberately. At Information, EF prints every SQL statement INCLUDING
|
||||
// parameter values, and the login path passes a normalised email address through it.
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
@@ -41,6 +44,10 @@
|
||||
"Refresh": {
|
||||
"PermitLimit": 20,
|
||||
"WindowSeconds": 60
|
||||
},
|
||||
"SentryTunnel": {
|
||||
"PermitLimit": 60,
|
||||
"WindowSeconds": 60
|
||||
}
|
||||
},
|
||||
"SecurityHeaders": {
|
||||
@@ -53,5 +60,16 @@
|
||||
],
|
||||
"AllowedScriptOrigins": [],
|
||||
"AllowedConnectOrigins": []
|
||||
},
|
||||
"Observability": {
|
||||
// Supplied per environment as Observability__SentryDsn. Empty means Sentry is skipped
|
||||
// entirely and console logging continues — a normal, supported state, not an error.
|
||||
"SentryDsn": "",
|
||||
// Falls back to ASPNETCORE_ENVIRONMENT when empty.
|
||||
"Environment": "",
|
||||
// Sentry's free plan counts transactions against the same quota as errors, and this setup's
|
||||
// value is in errors rather than performance traces.
|
||||
"TracesSampleRate": 0.1,
|
||||
"TunnelMaxPayloadBytes": 204800
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
@@ -46,7 +46,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken(role)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeTrue();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -54,7 +54,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("User")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -64,7 +64,7 @@ public class AdminTokenValidatorTests
|
||||
// signed by us. Reading claims without validating would have accepted this.
|
||||
var forged = CreateUnsignedToken("Owner");
|
||||
|
||||
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
|
||||
_validator.Validate($"Bearer {forged}").IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -83,7 +83,7 @@ public class AdminTokenValidatorTests
|
||||
|
||||
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,7 +91,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,7 +99,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -115,7 +115,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
// Never throws — an unusable header simply means "not an admin". Rejecting the request
|
||||
// is the authentication middleware's job, not the availability gate's.
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static string CreateToken(
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the committed <c>appsettings.json</c> of both hosts and runs the startup validators
|
||||
/// against them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything asserted here is a startup failure in production if it is wrong, which means the
|
||||
/// symptom is a host that will not boot after a release switch. Two specific reasons this exists:
|
||||
///
|
||||
/// The files carry <c>//</c> comments explaining non-obvious values. The JSON configuration
|
||||
/// provider does tolerate them, but "does tolerate" is worth verifying rather than assuming when
|
||||
/// the failure mode is both hosts refusing to start.
|
||||
///
|
||||
/// And <c>SecurityHeaders</c> policy names are validated with <c>ValidateOnStart</c>, so a typo in
|
||||
/// the committed file stops the process. Better to fail here than in a deployment.
|
||||
/// </remarks>
|
||||
public class DeployedConfigurationTests
|
||||
{
|
||||
public static TheoryData<string> HostConfigurations => new()
|
||||
{
|
||||
Path.Combine("host-config", "master.appsettings.json"),
|
||||
Path.Combine("host-config", "slave.appsettings.json")
|
||||
};
|
||||
|
||||
private static IConfigurationRoot Load(string relativePath)
|
||||
{
|
||||
File.Exists(relativePath).Should().BeTrue($"'{relativePath}' should be copied to the test output");
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.AddJsonFile(relativePath, optional: false)
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldParse_IncludingItsComments(string relativePath)
|
||||
{
|
||||
var act = () => Load(relativePath);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldPassSecurityHeadersStartupValidation(string relativePath)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddCmsSecurityHeaders(Load(relativePath));
|
||||
|
||||
var act = () => services.BuildServiceProvider().GetRequiredService<IStartupValidator>().Validate();
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldShipWithoutASentryDsn(string relativePath)
|
||||
{
|
||||
var options = Load(relativePath)
|
||||
.GetSection(ObservabilityOptions.SectionName)
|
||||
.Get<ObservabilityOptions>();
|
||||
|
||||
options.Should().NotBeNull();
|
||||
// A DSN identifies a project and belongs to an account. It is supplied per environment as
|
||||
// Observability__SentryDsn; the repository is not where it should be written down.
|
||||
options!.IsSentryConfigured.Should().BeFalse();
|
||||
options.TunnelMaxPayloadBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At <c>Information</c>, EF's command logging prints every SQL statement including parameter
|
||||
/// values, and the login path passes a normalised email address through it. Raising the
|
||||
/// default log level without pinning this category would start writing that to the console and
|
||||
/// to Sentry breadcrumbs.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldPinEfCommandLoggingBelowInformation(string relativePath)
|
||||
{
|
||||
var level = Load(relativePath)["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"];
|
||||
|
||||
level.Should().Be("Warning");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sentry breadcrumbs are capped by the logging level, so leaving the default at Warning would
|
||||
/// deliver every event with an empty breadcrumb trail — the feature present, configured, and
|
||||
/// useless.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldAllowInformationLevelLogging(string relativePath)
|
||||
{
|
||||
var configuration = Load(relativePath);
|
||||
|
||||
configuration["Logging:LogLevel:Default"].Should().Be("Information");
|
||||
configuration["Logging:LogLevel:Microsoft.AspNetCore"].Should().Be("Information");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldRateLimitTheSentryTunnel(string relativePath)
|
||||
{
|
||||
var configuration = Load(relativePath);
|
||||
|
||||
configuration.GetValue<int>("RateLimiting:SentryTunnel:PermitLimit").Should().BeGreaterThan(0);
|
||||
configuration.GetValue<int>("RateLimiting:SentryTunnel:WindowSeconds").Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>The admin SPA must never fall to the relaxed policy on the host that serves it.</summary>
|
||||
[Fact]
|
||||
public void MasterHostConfiguration_ShouldApplyTheStrictPolicyToAdmin()
|
||||
{
|
||||
var options = Load(Path.Combine("host-config", "master.appsettings.json"))
|
||||
.GetSection(SecurityHeadersOptions.SectionName)
|
||||
.Get<SecurityHeadersOptions>();
|
||||
|
||||
options.Should().NotBeNull();
|
||||
options!.Enabled.Should().BeTrue();
|
||||
options.PathPolicies
|
||||
.Should().Contain(rule => rule.PathPrefix == "/admin" && rule.Policy == CspPolicyCatalog.Strict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sentry;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the property that makes alert rules possible at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sentry groups log-derived events by their message. If a failed-login entry interpolated the
|
||||
/// email address, every distinct address would become its own Sentry issue and an alert rule of
|
||||
/// the form "more than 20 failed logins in five minutes" could never fire, because no single
|
||||
/// issue would ever reach 20. Everything would look like it worked: events arrive, they are
|
||||
/// visible, they are tagged. Only the alerting would silently be impossible.
|
||||
///
|
||||
/// These tests assert that the rendered message is identical across different argument values.
|
||||
/// </remarks>
|
||||
public class SecurityEventsTests
|
||||
{
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, eventId, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedLogin_ShouldRenderTheSameMessage_ForDifferentAccounts()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
|
||||
|
||||
logger.Entries.Should().HaveCount(2);
|
||||
logger.Entries[0].Message.Should().Be(logger.Entries[1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedLogin_ShouldCarryTheEventNameAndNoCredentials()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: false);
|
||||
|
||||
var entry = logger.Entries.Single();
|
||||
entry.Message.Should().Contain(SecurityEventNames.FailedLogin);
|
||||
entry.EventId.Id.Should().Be(SecurityEvents.FailedLoginEventId);
|
||||
entry.Level.Should().Be(LogLevel.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All six must cross the Sentry event threshold by construction rather than by a
|
||||
/// coincidence of configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllEvents_ShouldBeWarningOrAbove()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
|
||||
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
|
||||
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
|
||||
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.InvalidSignature);
|
||||
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
|
||||
|
||||
logger.Entries.Should().HaveCount(6);
|
||||
logger.Entries.Should().OnlyContain(e => e.Level >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEvents_ShouldUseDistinctEventIds()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
|
||||
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
|
||||
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
|
||||
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.Expired);
|
||||
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 1);
|
||||
|
||||
logger.Entries.Select(e => e.EventId.Id).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationFailure_ShouldBeCritical()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
|
||||
|
||||
logger.Entries.Single().Level.Should().Be(LogLevel.Critical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reason class distinguishes forgery from a stale tab. The token itself must never
|
||||
/// appear anywhere in the entry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AdminBypassRejected_ShouldCarryTheReasonClass()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.AdminBypassRejected(logger, "/admin", BypassRejectionReason.InvalidSignature);
|
||||
|
||||
logger.Entries.Single().Message.Should().Contain(nameof(BypassRejectionReason.InvalidSignature));
|
||||
}
|
||||
}
|
||||
|
||||
public class SecurityEventProcessorTests
|
||||
{
|
||||
private readonly SecurityEventProcessor _processor = new();
|
||||
|
||||
[Fact]
|
||||
public void Process_ShouldTagKnownSecurityEvents()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.SetExtra("SecurityEvent", SecurityEventNames.FailedLogin);
|
||||
|
||||
var processed = _processor.Process(sentryEvent);
|
||||
|
||||
processed!.Tags[SecurityEventProcessor.TagName].Should().Be(SecurityEventNames.FailedLogin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert rules filter on the tag rather than on message text, because a rule that matches
|
||||
/// nothing looks exactly like a rule with nothing to match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Process_ShouldNotTagUnknownValues()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.SetExtra("SecurityEvent", "something_else");
|
||||
|
||||
var processed = _processor.Process(sentryEvent);
|
||||
|
||||
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Process_ShouldLeaveOrdinaryEventsUntouched()
|
||||
{
|
||||
var processed = _processor.Process(new SentryEvent());
|
||||
|
||||
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using FluentAssertions;
|
||||
using Sentry;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// The most security-critical code in the observability unit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabling <c>SendDefaultPii</c> attaches request headers, and this application carries two
|
||||
/// standing credentials in them: the <c>refreshToken</c> cookie and the master/slave shared
|
||||
/// secret. Sending either to a third party would be worse than the problem the setting solves.
|
||||
/// These tests exist so that the scrub list cannot quietly shrink.
|
||||
/// </remarks>
|
||||
public class SentryEventScrubberTests
|
||||
{
|
||||
private readonly SentryEventScrubber _scrubber = new();
|
||||
|
||||
private static SentryEvent CreateEventWithCredentials()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.Request.Method = "POST";
|
||||
sentryEvent.Request.Url = "https://example.com/api/v1/Auth/login";
|
||||
sentryEvent.Request.QueryString = "returnUrl=/admin";
|
||||
sentryEvent.Request.Headers["Cookie"] = "refreshToken=super-secret-value";
|
||||
sentryEvent.Request.Headers["Set-Cookie"] = "refreshToken=freshly-issued; HttpOnly";
|
||||
sentryEvent.Request.Headers["Authorization"] = "Bearer eyJhbGciOi...";
|
||||
sentryEvent.Request.Headers["X-Master-Api-Key"] = "the-shared-secret";
|
||||
sentryEvent.Request.Headers["User-Agent"] = "Mozilla/5.0";
|
||||
sentryEvent.Request.Data = "{\"email\":\"a@b.nl\",\"password\":\"hunter2\"}";
|
||||
return sentryEvent;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Cookie")]
|
||||
[InlineData("Set-Cookie")]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("X-Master-Api-Key")]
|
||||
public void Scrub_ShouldRemoveCredentialHeaders(string header)
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Headers.Should().NotContainKey(header);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_ShouldRemoveTheRequestBody()
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Data.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retained fields are the entire diagnostic value of the request context. Scrubbing
|
||||
/// everything would be safe and useless.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Scrub_ShouldRetainDiagnosticFields()
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Method.Should().Be("POST");
|
||||
scrubbed.Request.Url.Should().Be("https://example.com/api/v1/Auth/login");
|
||||
scrubbed.Request.QueryString.Should().Be("returnUrl=/admin");
|
||||
scrubbed.Request.Headers.Should().ContainKey("User-Agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_ShouldNotThrow_WhenNoRequestContextIsPresent()
|
||||
{
|
||||
var act = () => _scrubber.Scrub(new SentryEvent());
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transactions carry request data too, and are the channel nobody thinks of. Raising
|
||||
/// TracesSampleRate without this would start leaking headers.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Cookie")]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("X-Master-Api-Key")]
|
||||
public void ScrubTransaction_ShouldRemoveCredentialHeaders(string header)
|
||||
{
|
||||
var transaction = new SentryTransaction("test", "http.server");
|
||||
transaction.Request.Headers["Cookie"] = "refreshToken=secret";
|
||||
transaction.Request.Headers["Authorization"] = "Bearer token";
|
||||
transaction.Request.Headers["X-Master-Api-Key"] = "secret";
|
||||
transaction.Request.Data = "body";
|
||||
|
||||
var scrubbed = _scrubber.ScrubTransaction(transaction);
|
||||
|
||||
scrubbed.Request.Headers.Should().NotContainKey(header);
|
||||
scrubbed.Request.Data.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A guard against the list shrinking by accident. Each entry has a reason recorded next to
|
||||
/// it in the implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemovedHeaders_ShouldCoverAllFourCredentialCarriers()
|
||||
{
|
||||
SentryEventScrubber.RemovedHeaders.Should().BeEquivalentTo(
|
||||
["Cookie", "Set-Cookie", "Authorization", "X-Master-Api-Key"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// The destination is the rule that keeps the tunnel from being a server-side request forgery
|
||||
/// primitive: it is computed once from configuration, and nothing in a request can influence it.
|
||||
/// </summary>
|
||||
public class SentryTunnelTargetTests
|
||||
{
|
||||
private static SentryTunnelTarget Create(string dsn, int maxBytes = 204_800) =>
|
||||
new(Options.Create(new ObservabilityOptions
|
||||
{
|
||||
SentryDsn = dsn,
|
||||
TunnelMaxPayloadBytes = maxBytes
|
||||
}));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldDeriveTheEnvelopeEndpoint_FromTheDsn()
|
||||
{
|
||||
var target = Create("https://abc123@o4511795618185216.ingest.de.sentry.io/4511795622838352");
|
||||
|
||||
target.IsConfigured.Should().BeTrue();
|
||||
target.EnvelopeEndpoint.Should().Be(
|
||||
new Uri("https://o4511795618185216.ingest.de.sentry.io/api/4511795622838352/envelope/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldNotBeConfigured_WithoutADsn()
|
||||
{
|
||||
var target = Create(string.Empty);
|
||||
|
||||
target.IsConfigured.Should().BeFalse();
|
||||
target.EnvelopeEndpoint.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails at startup rather than per request, consistent with failing closed everywhere else.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("not-a-uri")]
|
||||
[InlineData("https://abc123@ingest.sentry.io")] // no project id
|
||||
[InlineData("https://abc123@ingest.sentry.io/")]
|
||||
public void Constructor_ShouldThrow_ForAnUnusableDsn(string dsn)
|
||||
{
|
||||
var act = () => Create(dsn);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldFallBackToADefault_WhenTheSizeCapIsNotPositive()
|
||||
{
|
||||
var target = Create("https://abc@host.sentry.io/42", maxBytes: 0);
|
||||
|
||||
target.MaxPayloadBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Kept in sync with the frontend's <c>tunnel</c> option and the vite dev proxy.</summary>
|
||||
[Fact]
|
||||
public void Path_ShouldBeOutsideTheVersionedApi()
|
||||
{
|
||||
SentryTunnelTarget.Path.Should().Be("/sentry-tunnel");
|
||||
SentryTunnelTarget.Path.Should().NotStartWith("/api/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// The rejection reason is what makes a rejected admin bypass actionable: InvalidSignature means
|
||||
/// someone is forging tokens, Expired is almost always an administrator with a stale tab, and an
|
||||
/// alert that cannot tell those apart is one nobody acts on.
|
||||
/// </summary>
|
||||
public class AdminTokenRejectionReasonTests
|
||||
{
|
||||
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
|
||||
private const string Issuer = "SlpModularCms";
|
||||
private const string Audience = "SlpModularCmsPortal";
|
||||
|
||||
private readonly AdminTokenValidator _validator = new(JwtTokenValidation.Create(new JwtSettings
|
||||
{
|
||||
Secret = Secret,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience
|
||||
}));
|
||||
|
||||
private static string CreateToken(
|
||||
string? secret = null,
|
||||
string? issuer = null,
|
||||
string? audience = null,
|
||||
string role = "Owner",
|
||||
TimeSpan? lifetime = null)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret ?? Secret));
|
||||
var expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(30));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer ?? Issuer,
|
||||
audience: audience ?? Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
notBefore: expires.AddMinutes(-35),
|
||||
expires: expires,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Basic abc")]
|
||||
[InlineData("Bearer ")]
|
||||
public void Validate_ShouldReportAbsent_WhenThereIsNoBearerToken(string? header)
|
||||
{
|
||||
var result = _validator.Validate(header);
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Absent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportInvalidSignature_ForAForgedToken()
|
||||
{
|
||||
var forged = CreateToken(secret: "AnEntirelyDifferentSecretThatIsAlsoLongEnough!!!!!");
|
||||
|
||||
var result = _validator.Validate($"Bearer {forged}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.InvalidSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportExpired_ForAStaleToken()
|
||||
{
|
||||
var expired = CreateToken(lifetime: TimeSpan.FromMinutes(-10));
|
||||
|
||||
var result = _validator.Validate($"Bearer {expired}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportWrongIssuer()
|
||||
{
|
||||
var token = CreateToken(issuer: "SomeoneElse");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.WrongIssuer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportWrongAudience()
|
||||
{
|
||||
var token = CreateToken(audience: "SomeOtherAudience");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.WrongAudience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportNotAdmin_ForAValidNonAdminToken()
|
||||
{
|
||||
var token = CreateToken(role: "User");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.NotAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportMalformed_ForGarbage()
|
||||
{
|
||||
var result = _validator.Validate("Bearer not.a.jwt");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldStillSucceed_ForAValidAdminToken()
|
||||
{
|
||||
var token = CreateToken();
|
||||
|
||||
_validator.Validate($"Bearer {token}").IsVerifiedAdmin.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -38,7 +39,7 @@ public class AuthServiceTests
|
||||
RefreshTokenExpiryDays = 7
|
||||
};
|
||||
|
||||
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings));
|
||||
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings), NullLogger<AuthService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -31,4 +31,14 @@
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The real, committed appsettings of both hosts, so DeployedConfigurationTests can validate them
|
||||
rather than a copy that drifts. Linked, not duplicated: a test asserting a second file proves
|
||||
nothing about what actually ships.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Content Include="..\SlpModularCms.Api\appsettings.json" Link="host-config\master.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\SlpModularCms.Api.Slave\appsettings.json" Link="host-config\slave.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -3,7 +3,9 @@ using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sentry;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting;
|
||||
|
||||
@@ -79,10 +81,15 @@ public static class DatabaseMigrationExtensions
|
||||
{
|
||||
// Logged with the failure reason but never the connection string or credentials —
|
||||
// this message travels to the console and to Sentry.
|
||||
logger.LogCritical(
|
||||
ex,
|
||||
"Core database migration failed after {Attempts} attempt(s). The application will not start.",
|
||||
attempt);
|
||||
SecurityEvents.MigrationFailure(logger, ex, attempt);
|
||||
|
||||
// The SDK batches and sends in the background, and this process is about to exit
|
||||
// — which would kill the sender before it transmits. Without this flush the one
|
||||
// Critical event in the whole system is also the event most likely never to
|
||||
// arrive. Bounded at five seconds: a host that cannot reach its database is
|
||||
// already down, and five seconds buys the alert that says why. A no-op when
|
||||
// Sentry was never initialised, so the no-DSN path is unaffected.
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static class LoggingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures structured console logging with a correlation identifier on every entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Must be called <b>before</b> Sentry is registered, so that a problem initialising Sentry
|
||||
/// is itself logged.
|
||||
/// </remarks>
|
||||
public static ILoggingBuilder AddCmsLogging(this ILoggingBuilder logging, IHostEnvironment environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logging);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
|
||||
// The default host already added a console provider without IncludeScopes. Adding a
|
||||
// second one prints every line twice — once with the correlation ID and once without —
|
||||
// which reads as a logging bug and wastes real time.
|
||||
logging.ClearProviders();
|
||||
|
||||
// The correlation identifier required by SECURITY-03, resolved as the W3C trace ID from
|
||||
// the ambient Activity rather than HttpContext.TraceIdentifier.
|
||||
//
|
||||
// Two reasons, the first decisive:
|
||||
// 1. It crosses the master/slave HTTP boundary. HttpClient injects traceparent and the
|
||||
// slave's hosting layer adopts it, so both sides' log entries carry the SAME value.
|
||||
// "The master says the slave rejected its API key — what did the slave see?" is the
|
||||
// hardest diagnostic question in this codebase, and TraceIdentifier, being host-local,
|
||||
// cannot answer it at all.
|
||||
// 2. It is already the value the client is shown: ASP.NET Core's ProblemDetails writes
|
||||
// traceId as Activity.Current?.Id ?? HttpContext.TraceIdentifier. So the log entry,
|
||||
// the Sentry event, the slave's log entry and the browser's error response all carry
|
||||
// one value, and an operator handed a traceId from a screenshot can find the request.
|
||||
//
|
||||
// This puts TraceId into the scope of EVERY entry from every category, framework included,
|
||||
// without touching a single call site — which matters, because the alternative is
|
||||
// remembering to pass an identifier into every existing log call.
|
||||
logging.Configure(options =>
|
||||
options.ActivityTrackingOptions =
|
||||
ActivityTrackingOptions.TraceId |
|
||||
ActivityTrackingOptions.SpanId |
|
||||
ActivityTrackingOptions.ParentId);
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
// A developer reads this with their eyes, and JSON is hostile to that.
|
||||
logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = true;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// In test and production the process is supervised and its stdout lands in the
|
||||
// journal, where JSON is greppable and TraceId is a field rather than a substring.
|
||||
logging.AddJsonConsole(options => options.IncludeScopes = true);
|
||||
}
|
||||
|
||||
return logging;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for logging and error reporting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every value here is optional. An absent DSN is a normal, fully supported state — local
|
||||
/// development and any deployment without Sentry must work unchanged — so nothing in this
|
||||
/// section is required and nothing warns about being empty.
|
||||
///
|
||||
/// Deliberately <b>not</b> configurable: the scrub list and the tunnel's destination host. Both
|
||||
/// are security-critical, and making either configurable would create a way to switch the
|
||||
/// protection off — the scrub list by omission, the destination by turning the tunnel into a
|
||||
/// request-forgery primitive.
|
||||
/// </remarks>
|
||||
public sealed class ObservabilityOptions
|
||||
{
|
||||
public const string SectionName = "Observability";
|
||||
|
||||
/// <summary>
|
||||
/// Sentry DSN. Empty means Sentry is skipped entirely.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a secret in the usual sense — it identifies a project and permits event submission,
|
||||
/// and the frontend's copy is visible in the page source. It still comes from an environment
|
||||
/// variable (<c>Observability__SentryDsn</c>) in test and production rather than being
|
||||
/// committed: it belongs to an account, and the repository is not where it should be written
|
||||
/// down.
|
||||
/// </remarks>
|
||||
public string SentryDsn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Environment tag. Falls back to <c>ASPNETCORE_ENVIRONMENT</c> when empty.</summary>
|
||||
public string Environment { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Performance sampling rate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept low: Sentry's free plan counts transactions against the same quota as errors, and
|
||||
/// this setup's value is in errors rather than performance traces.
|
||||
/// </remarks>
|
||||
public double TracesSampleRate { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Hard upper bound on a tunnelled envelope. Envelopes with a stack trace and breadcrumbs
|
||||
/// run to tens of kilobytes, so this is generous without being an allocation risk on a
|
||||
/// Raspberry Pi.
|
||||
/// </summary>
|
||||
public int TunnelMaxPayloadBytes { get; set; } = 204_800;
|
||||
|
||||
public bool IsSentryConfigured => !string.IsNullOrWhiteSpace(SentryDsn);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Reports authorization denials, then defers entirely to the framework's own handler.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hooked in at <see cref="IAuthorizationMiddlewareResultHandler"/> rather than inside an
|
||||
/// <see cref="IAuthorizationHandler"/>, because a handler sees one requirement at a time: a
|
||||
/// requirement that does not succeed is not the same as the request being denied, and reporting
|
||||
/// from there would produce events for requests that were ultimately allowed. This interface sees
|
||||
/// the final result, which is the thing worth alerting on.
|
||||
///
|
||||
/// The response itself is unchanged — this only observes.
|
||||
/// </remarks>
|
||||
public sealed class SecurityAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
|
||||
{
|
||||
private readonly AuthorizationMiddlewareResultHandler _inner = new();
|
||||
private readonly ILogger<SecurityAuthorizationResultHandler> _logger;
|
||||
|
||||
public SecurityAuthorizationResultHandler(ILogger<SecurityAuthorizationResultHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleAsync(
|
||||
RequestDelegate next,
|
||||
HttpContext context,
|
||||
AuthorizationPolicy policy,
|
||||
PolicyAuthorizationResult authorizeResult)
|
||||
{
|
||||
// Challenged means "not authenticated yet" — a 401 that the browser resolves by logging
|
||||
// in, and an entirely ordinary event. Forbidden means an authenticated caller reached
|
||||
// something they lack the rights for, which is the case worth knowing about.
|
||||
if (authorizeResult.Forbidden)
|
||||
{
|
||||
SecurityEvents.AuthorizationDenied(
|
||||
_logger,
|
||||
context.Request.Path,
|
||||
DescribePolicy(policy));
|
||||
}
|
||||
|
||||
return _inner.HandleAsync(next, context, policy, authorizeResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the requirements rather than the policy, because ASP.NET Core resolves the policy
|
||||
/// object before this point and the original name is no longer available.
|
||||
/// </summary>
|
||||
private static string DescribePolicy(AuthorizationPolicy policy) =>
|
||||
string.Join(", ", policy.Requirements.Select(requirement => requirement.GetType().Name));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Frozen;
|
||||
using Sentry;
|
||||
using Sentry.Extensibility;
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Promotes a security event's constant name to a Sentry tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Alert rules then filter on <c>security_event:failed_login</c>, which survives a change to the
|
||||
/// message wording. Matching on message text would not — and it would break silently, because a
|
||||
/// rule that matches nothing looks exactly like a rule with nothing to match.
|
||||
/// </remarks>
|
||||
public sealed class SecurityEventProcessor : ISentryEventProcessor
|
||||
{
|
||||
public const string TagName = "security_event";
|
||||
|
||||
private static readonly FrozenSet<string> KnownEventNames = new[]
|
||||
{
|
||||
SecurityEventNames.FailedLogin,
|
||||
SecurityEventNames.AuthorizationDenied,
|
||||
SecurityEventNames.MasterApiKeyRejected,
|
||||
SecurityEventNames.AdminBypassRejected,
|
||||
SecurityEventNames.RateLimitTriggered,
|
||||
SecurityEventNames.MigrationFailure
|
||||
}.ToFrozenSet(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The structured property name that <see cref="SecurityEvents"/> attaches to every entry.
|
||||
/// </summary>
|
||||
private const string PropertyName = "SecurityEvent";
|
||||
|
||||
public SentryEvent? Process(SentryEvent @event)
|
||||
{
|
||||
if (@event is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (@event.Extra.TryGetValue(PropertyName, out var value) &&
|
||||
value is string name &&
|
||||
KnownEventNames.Contains(name))
|
||||
{
|
||||
@event.SetTag(TagName, name);
|
||||
}
|
||||
|
||||
return @event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Sentry;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>Removes credentials from an outbound Sentry event.</summary>
|
||||
public interface ISentryEventScrubber
|
||||
{
|
||||
SentryEvent Scrub(SentryEvent sentryEvent);
|
||||
|
||||
SentryTransaction ScrubTransaction(SentryTransaction transaction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips credential-bearing headers and the request body before anything leaves the process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabling <c>SendDefaultPii</c> attaches request headers, and "PII" understates what this
|
||||
/// application carries in them: a <c>refreshToken</c> cookie and the master/slave shared secret
|
||||
/// are <b>credentials</b>, not merely personal data. Sending them to a third party would be
|
||||
/// worse than the problem the setting solves.
|
||||
///
|
||||
/// This runs <b>in-process, before transmission</b>. Sentry offers server-side scrubbing, but by
|
||||
/// then the secret has already left the building — doing it here is the only version that
|
||||
/// actually protects anything.
|
||||
///
|
||||
/// It is a class rather than a lambda inside the <c>UseSentry</c> callback because it is the most
|
||||
/// security-critical code in this unit, and a lambda there cannot be unit-tested without
|
||||
/// initialising the SDK.
|
||||
/// </remarks>
|
||||
public sealed class SentryEventScrubber : ISentryEventScrubber
|
||||
{
|
||||
/// <summary>
|
||||
/// A <c>static readonly</c> array in code, never an options property: a configurable scrub
|
||||
/// list is a supported way to switch the protection off by omission.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RemovedHeaders => RemovedHeaderNames;
|
||||
|
||||
private static readonly string[] RemovedHeaderNames =
|
||||
[
|
||||
// The whole header, not one cookie. It carries the refreshToken, and removing a single
|
||||
// cookie by rewriting the header is error-prone in a way that removing the header is not.
|
||||
"Cookie",
|
||||
|
||||
// The response counterpart. The login and refresh responses ISSUE the refreshToken here,
|
||||
// so scrubbing the request cookie while sending this one would protect nothing.
|
||||
"Set-Cookie",
|
||||
|
||||
// Bearer token.
|
||||
"Authorization",
|
||||
|
||||
// The master/slave shared secret. Not mentioned when SendDefaultPii was chosen, but the
|
||||
// same class of secret — and it would otherwise be sent to a third party on every error
|
||||
// raised during a master/slave call.
|
||||
"X-Master-Api-Key"
|
||||
];
|
||||
|
||||
public SentryEvent Scrub(SentryEvent sentryEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sentryEvent);
|
||||
|
||||
ScrubRequest(sentryEvent.Request);
|
||||
return sentryEvent;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Transactions carry request data too. Scrubbing only events would leave a second channel
|
||||
/// open — less obvious precisely because nobody thinks of a transaction as containing
|
||||
/// headers — so raising <c>TracesSampleRate</c> later, without touching this file, would
|
||||
/// start leaking.
|
||||
/// </remarks>
|
||||
public SentryTransaction ScrubTransaction(SentryTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
ScrubRequest(transaction.Request);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What is deliberately <b>retained</b>: method, path, query string, user agent, IP address,
|
||||
/// authenticated username and the correlation ID — all genuinely diagnostic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Query strings are kept even though invitation tokens travel as <c>?token=…</c> on one
|
||||
/// endpoint. That token is single-use and time-limited rather than a standing credential, and
|
||||
/// knowing which endpoint was called outweighs it. Recorded so the trade-off is visible
|
||||
/// rather than accidental.
|
||||
/// </remarks>
|
||||
private static void ScrubRequest(SentryRequest request)
|
||||
{
|
||||
foreach (var header in RemovedHeaderNames)
|
||||
{
|
||||
request.Headers.Remove(header);
|
||||
}
|
||||
|
||||
// Belt and braces: MaxRequestBodySize is None, so the body should never have been
|
||||
// captured in the first place. Nulling it here means a future change to that option
|
||||
// cannot quietly start shipping login payloads.
|
||||
request.Data = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sentry.AspNetCore;
|
||||
using Sentry.Extensibility;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static class SentryExtensions
|
||||
{
|
||||
public static IServiceCollection AddCmsObservability(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ObservabilityOptions>()
|
||||
.Bind(configuration.GetSection(ObservabilityOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<ISentryEventScrubber, SentryEventScrubber>();
|
||||
services.AddSingleton<SecurityEventProcessor>();
|
||||
|
||||
// Resolving this at startup is what makes an unparseable DSN a startup failure rather
|
||||
// than a per-request one.
|
||||
services.AddSingleton<SentryTunnelTarget>();
|
||||
|
||||
services.AddHttpClient(SentryTunnelExtensions.HttpClientName, client =>
|
||||
{
|
||||
// Short, and no retry. A dropped error report is acceptable; a request thread held
|
||||
// open by an anonymous caller is not.
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Sentry, or does nothing at all when no DSN is configured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An absent DSN is a normal, supported state and produces <b>no warning</b>. Local
|
||||
/// development is the common case, and a startup warning that always appears trains people to
|
||||
/// ignore startup warnings — including the ones that matter.
|
||||
/// </remarks>
|
||||
public static IWebHostBuilder UseCmsSentry(this IWebHostBuilder webHost, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(webHost);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var settings = configuration.GetSection(ObservabilityOptions.SectionName).Get<ObservabilityOptions>()
|
||||
?? new ObservabilityOptions();
|
||||
|
||||
if (!settings.IsSentryConfigured)
|
||||
{
|
||||
return webHost;
|
||||
}
|
||||
|
||||
// Stateless, and needed inside a callback that runs before any service provider exists.
|
||||
// The same types are also registered in DI, where the tests reach them.
|
||||
var scrubber = new SentryEventScrubber();
|
||||
var processor = new SecurityEventProcessor();
|
||||
|
||||
return webHost.UseSentry(options =>
|
||||
{
|
||||
options.Dsn = settings.SentryDsn;
|
||||
options.Environment = string.IsNullOrWhiteSpace(settings.Environment) ? null : settings.Environment;
|
||||
options.Release = GetRelease();
|
||||
options.TracesSampleRate = settings.TracesSampleRate;
|
||||
|
||||
// Reconciles two answers rather than choosing between them: the console gets
|
||||
// everything at Information, Sentry gets warnings and errors as EVENTS — still more
|
||||
// than exceptions — and informational entries travel attached to those events as
|
||||
// breadcrumbs. Sending every framework Information entry as an event would mean one
|
||||
// event per request, exhausting the free plan within hours and burying real errors in
|
||||
// request noise.
|
||||
options.MinimumBreadcrumbLevel = LogLevel.Information;
|
||||
options.MinimumEventLevel = LogLevel.Warning;
|
||||
|
||||
// Attaches request context — and therefore requires the scrubber below.
|
||||
options.SendDefaultPii = true;
|
||||
|
||||
// The safest version of "the request body is never sent to Sentry" is that it is
|
||||
// never read into an event at all. This is the SDK default; set explicitly so that a
|
||||
// future change to it has to be deliberate.
|
||||
options.MaxRequestBodySize = RequestSize.None;
|
||||
|
||||
options.SetBeforeSend((sentryEvent, _) => scrubber.Scrub(sentryEvent));
|
||||
options.SetBeforeSendTransaction((transaction, _) => scrubber.ScrubTransaction(transaction));
|
||||
options.AddEventProcessor(processor);
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetRelease()
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
|
||||
|
||||
return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Forwards browser Sentry envelopes through this application's own origin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ad blockers block requests to Sentry domains with <c>ERR_BLOCKED_BY_CLIENT</c>. Without a
|
||||
/// tunnel, errors are lost precisely for the users who have an ad blocker — a silently biased
|
||||
/// sample of exactly the group most likely to have browser oddities. It also keeps browser
|
||||
/// traffic same-origin, so the CSP needs <c>connect-src 'self'</c> and no external Sentry origin.
|
||||
///
|
||||
/// The reference project tunnels through nginx. Relying on server configuration is what this
|
||||
/// deployment model forbids, so the application forwards it instead.
|
||||
/// </remarks>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static class SentryTunnelExtensions
|
||||
{
|
||||
public const string HttpClientName = "sentry-tunnel";
|
||||
public const string RateLimiterName = "sentry-tunnel";
|
||||
|
||||
public static IEndpointRouteBuilder MapSentryTunnel(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(SentryTunnelTarget.Path, HandleAsync)
|
||||
// Anonymous on purpose: the errors most worth capturing include authentication
|
||||
// failures, so error reporting must work for a user whose session just expired.
|
||||
.AllowAnonymous()
|
||||
// An anonymous endpoint that triggers an outbound HTTPS request per call is a free
|
||||
// amplifier and a way to burn the Sentry plan's quota from outside. The other three
|
||||
// controls — fixed destination, size cap, no-DSN-no-forwarding — bound what each call
|
||||
// can do but not how many calls there can be.
|
||||
.RequireRateLimiting(RateLimiterName)
|
||||
// Not a versioned CMS API and not part of the public contract.
|
||||
.ExcludeFromDescription()
|
||||
.WithName("SentryTunnel");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext context,
|
||||
SentryTunnelTarget target,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!target.IsConfigured)
|
||||
{
|
||||
// 404 rather than 503: with no DSN configured this endpoint genuinely does not exist.
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Content-Length is absent under chunked transfer encoding and is attacker-controlled in
|
||||
// any case, so this is an optimisation rather than the control.
|
||||
if (context.Request.ContentLength > target.MaxPayloadBytes)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
// This bounded read IS the control. Trusting Content-Length alone would give an anonymous
|
||||
// caller an unbounded memory allocation on a Raspberry Pi.
|
||||
var payload = await ReadAtMostAsync(context.Request.Body, target.MaxPayloadBytes, cancellationToken);
|
||||
if (payload is null)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger(typeof(SentryTunnelExtensions).FullName!);
|
||||
|
||||
try
|
||||
{
|
||||
using var content = new ByteArrayContent(payload);
|
||||
var client = httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
// target.EnvelopeEndpoint was derived from configuration at startup. No part of the
|
||||
// destination comes from this request, and that is what separates a tunnel from a
|
||||
// server-side request forgery primitive.
|
||||
using var response = await client.PostAsync(target.EnvelopeEndpoint, content, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Sentry tunnel: ingest returned {StatusCode} for a {ByteCount}-byte envelope.",
|
||||
(int)response.StatusCode, payload.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Sentry tunnel: forwarding an envelope failed.");
|
||||
}
|
||||
|
||||
// Accepted regardless of what happened upstream. The browser must not retry or log a
|
||||
// console error over a failed error report: failing to report an error must not itself
|
||||
// become an error.
|
||||
return Results.Accepted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads at most <paramref name="maxBytes"/>. Returns null when the stream carries more.
|
||||
/// </summary>
|
||||
private static async Task<byte[]?> ReadAtMostAsync(Stream body, int maxBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
// One byte of headroom, so "exactly at the limit" and "over the limit" are distinguishable.
|
||||
var buffer = new byte[maxBytes + 1];
|
||||
var total = 0;
|
||||
|
||||
while (total < buffer.Length)
|
||||
{
|
||||
var read = await body.ReadAsync(buffer.AsMemory(total), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
total += read;
|
||||
}
|
||||
|
||||
return total > maxBytes ? null : buffer[..total];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// The one destination the Sentry tunnel is allowed to forward to, derived from the configured
|
||||
/// DSN at startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This class is the rule that keeps the tunnel from being a liability.</b> An anonymous
|
||||
/// endpoint that makes an outbound request on demand is a server-side request forgery primitive
|
||||
/// if the destination comes from the caller. Computing it once, from configuration, and never
|
||||
/// reading anything from the request makes that structurally impossible rather than merely
|
||||
/// avoided by the current code.
|
||||
///
|
||||
/// An unparseable DSN fails at startup rather than per request, consistent with the security
|
||||
/// headers unit and with failing closed generally.
|
||||
/// </remarks>
|
||||
public sealed class SentryTunnelTarget
|
||||
{
|
||||
/// <summary>Same path the reference project uses, where nginx serves it.</summary>
|
||||
/// <remarks>
|
||||
/// Here the application forwards it, because relying on reverse-proxy configuration is
|
||||
/// exactly what this deployment model forbids. Keeping the path identical means the frontend
|
||||
/// <c>tunnel</c> option, the vite dev proxy and an operator's muscle memory all carry over
|
||||
/// unchanged between the two workspaces.
|
||||
///
|
||||
/// Deliberately outside <c>/api/v1</c>: it is not a versioned CMS API, it must not appear in
|
||||
/// the OpenAPI document, and <c>/api/v1</c> maps to the strict CSP policy for reasons that
|
||||
/// have nothing to do with this endpoint.
|
||||
/// </remarks>
|
||||
public const string Path = "/sentry-tunnel";
|
||||
|
||||
/// <summary>Null when no DSN is configured — the tunnel then accepts nothing.</summary>
|
||||
public Uri? EnvelopeEndpoint { get; }
|
||||
|
||||
public int MaxPayloadBytes { get; }
|
||||
|
||||
public bool IsConfigured => EnvelopeEndpoint is not null;
|
||||
|
||||
public SentryTunnelTarget(IOptions<ObservabilityOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var value = options.Value;
|
||||
|
||||
MaxPayloadBytes = value.TunnelMaxPayloadBytes > 0 ? value.TunnelMaxPayloadBytes : 204_800;
|
||||
EnvelopeEndpoint = value.IsSentryConfigured ? BuildEnvelopeEndpoint(value.SentryDsn) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns <c>https://{publicKey}@{host}/{projectId}</c> into
|
||||
/// <c>https://{host}/api/{projectId}/envelope/</c>.
|
||||
/// </summary>
|
||||
private static Uri BuildEnvelopeEndpoint(string dsn)
|
||||
{
|
||||
if (!Uri.TryCreate(dsn.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{ObservabilityOptions.SectionName}:SentryDsn is not a valid absolute URI.");
|
||||
}
|
||||
|
||||
var projectId = uri.AbsolutePath.Trim('/');
|
||||
if (projectId.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{ObservabilityOptions.SectionName}:SentryDsn does not contain a project id.");
|
||||
}
|
||||
|
||||
return new Uri($"{uri.Scheme}://{uri.Host}/api/{projectId}/envelope/");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Security;
|
||||
|
||||
@@ -30,18 +31,18 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
|
||||
_validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters));
|
||||
}
|
||||
|
||||
public bool IsVerifiedAdmin(string? authorizationHeader)
|
||||
public AdminTokenResult Validate(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authorizationHeader) ||
|
||||
!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
return Rejected(BypassRejectionReason.Absent);
|
||||
}
|
||||
|
||||
var token = authorizationHeader[BearerPrefix.Length..].Trim();
|
||||
if (token.Length == 0)
|
||||
{
|
||||
return false;
|
||||
return Rejected(BypassRejectionReason.Absent);
|
||||
}
|
||||
|
||||
ClaimsPrincipal principal;
|
||||
@@ -52,13 +53,34 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
|
||||
// whether to serve is this component's job; returning 401 is not.
|
||||
principal = _handler.ValidateToken(token, _validationParameters, out _);
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
return false;
|
||||
return Rejected(Classify(ex));
|
||||
}
|
||||
|
||||
return AdminRoles.Any(role => principal.IsInRole(role))
|
||||
|| principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
|
||||
|| principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
|
||||
var isAdmin = AdminRoles.Any(role => principal.IsInRole(role))
|
||||
|| principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
|
||||
|| principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
|
||||
|
||||
return isAdmin
|
||||
? new AdminTokenResult(true, default)
|
||||
: Rejected(BypassRejectionReason.NotAdmin);
|
||||
}
|
||||
|
||||
private static AdminTokenResult Rejected(BypassRejectionReason reason) => new(false, reason);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a validation exception onto a reason class. The exception type is the only thing
|
||||
/// inspected — never the token, and never the exception message, which can quote it.
|
||||
/// </summary>
|
||||
private static BypassRejectionReason Classify(Exception exception) => exception switch
|
||||
{
|
||||
SecurityTokenExpiredException => BypassRejectionReason.Expired,
|
||||
// Covers SecurityTokenSignatureKeyNotFoundException too, which derives from it — a key
|
||||
// that cannot be found and a signature that does not verify are the same signal here.
|
||||
SecurityTokenInvalidSignatureException => BypassRejectionReason.InvalidSignature,
|
||||
SecurityTokenInvalidIssuerException => BypassRejectionReason.WrongIssuer,
|
||||
SecurityTokenInvalidAudienceException => BypassRejectionReason.WrongAudience,
|
||||
_ => BypassRejectionReason.Malformed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of validating an admin bypass token.
|
||||
/// </summary>
|
||||
/// <param name="IsVerifiedAdmin">True only for a fully valid Owner or Administrator token.</param>
|
||||
/// <param name="Reason">
|
||||
/// Why a rejected token was rejected; meaningless when <paramref name="IsVerifiedAdmin"/> is true.
|
||||
/// Never the token or any part of it.
|
||||
/// </param>
|
||||
public readonly record struct AdminTokenResult(bool IsVerifiedAdmin, BypassRejectionReason Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a request carries a genuinely valid Owner or Administrator token.
|
||||
/// </summary>
|
||||
@@ -12,13 +24,25 @@ namespace SlpModularCms.Core.Hosting.Security;
|
||||
public interface IAdminTokenValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true only when the supplied Authorization header contains a bearer token that
|
||||
/// validates successfully and carries the Owner or Administrator role.
|
||||
/// Validates the supplied Authorization header and reports both the decision and, on
|
||||
/// rejection, its cause.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">Raw Authorization header value; may be null or empty.</param>
|
||||
/// <returns>
|
||||
/// True when the caller is a verified Owner or Administrator; false in every other case,
|
||||
/// including an absent, malformed, forged, expired or non-admin token. Never throws.
|
||||
/// A verified-admin result only for a bearer token that validates successfully and carries the
|
||||
/// Owner or Administrator role. Every other case — absent, malformed, forged, expired or
|
||||
/// non-admin — is a rejection with a reason. Never throws.
|
||||
/// </returns>
|
||||
bool IsVerifiedAdmin(string? authorizationHeader);
|
||||
/// <remarks>
|
||||
/// <b>Deliberately the only method on this interface.</b> An earlier shape had a plain
|
||||
/// boolean overload alongside this one, and the difference was invisible at a call site: a
|
||||
/// caller using the boolean form got the right access decision and silently emitted no
|
||||
/// security event. One method means the reason cannot be skipped by accident.
|
||||
///
|
||||
/// The reason class matters operationally:
|
||||
/// <see cref="BypassRejectionReason.InvalidSignature"/> suggests forgery, while
|
||||
/// <see cref="BypassRejectionReason.Expired"/> is usually an administrator with a stale tab,
|
||||
/// and an alert that cannot tell those apart is one nobody acts on.
|
||||
/// </remarks>
|
||||
AdminTokenResult Validate(string? authorizationHeader);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Data;
|
||||
@@ -16,6 +18,8 @@ using SlpModularCms.Core.Identity.Authorization;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
@@ -86,6 +90,11 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddSingleton<IAuthorizationHandler, HierarchicalRoleHandler>();
|
||||
|
||||
// Observes the final authorization result so a denial becomes an alertable event.
|
||||
// Replaces the framework's handler and delegates straight back to it — the response is
|
||||
// unchanged.
|
||||
services.AddSingleton<IAuthorizationMiddlewareResultHandler, SecurityAuthorizationResultHandler>();
|
||||
|
||||
// 6. Exception Handling
|
||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
services.AddProblemDetails();
|
||||
@@ -126,6 +135,24 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
// Without this callback a brute-force attempt against /api/v1/Auth/login returns 429
|
||||
// and leaves NO trace anywhere — the one rate limiter this application has would be
|
||||
// entirely unobservable, and the alert rule for it could never fire.
|
||||
options.OnRejected = (context, _) =>
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(typeof(ServiceCollectionExtensions).FullName!);
|
||||
|
||||
SecurityEvents.RateLimitTriggered(
|
||||
logger,
|
||||
context.HttpContext.GetEndpoint()?.Metadata
|
||||
.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName ?? "(unknown)",
|
||||
context.HttpContext.Request.Path);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
options.AddFixedWindowLimiter("login", opt =>
|
||||
{
|
||||
var settings = configuration.GetSection("RateLimiting:Login");
|
||||
@@ -142,6 +169,17 @@ public static class ServiceCollectionExtensions
|
||||
opt.SegmentsPerWindow = 4;
|
||||
opt.QueueLimit = 0;
|
||||
});
|
||||
|
||||
// Guards the anonymous Sentry tunnel. Generous, because a burst of browser errors is
|
||||
// exactly when reporting matters most, but bounded, because the endpoint makes an
|
||||
// outbound HTTPS request per call.
|
||||
options.AddFixedWindowLimiter(SentryTunnelExtensions.RateLimiterName, opt =>
|
||||
{
|
||||
var settings = configuration.GetSection("RateLimiting:SentryTunnel");
|
||||
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 60);
|
||||
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
|
||||
opt.QueueLimit = 0;
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -4,12 +4,14 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Observability;
|
||||
|
||||
namespace SlpModularCms.Core.Identity.Services;
|
||||
|
||||
@@ -18,15 +20,18 @@ public class AuthService : IAuthService
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly JwtSettings _jwtSettings;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
public AuthService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ApplicationDbContext context,
|
||||
IOptions<JwtSettings> jwtSettings)
|
||||
IOptions<JwtSettings> jwtSettings,
|
||||
ILogger<AuthService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_context = context;
|
||||
_jwtSettings = jwtSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> AuthenticateAsync(string email, string password)
|
||||
@@ -34,6 +39,11 @@ public class AuthService : IAuthService
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user == null || !user.IsActive || !await _userManager.CheckPasswordAsync(user, password))
|
||||
{
|
||||
// Repeated occurrences suggest an attack or a forgotten password, and whether the
|
||||
// account exists is what tells those apart. Deliberately absent from the event: the
|
||||
// password, the attempted password, and the address itself.
|
||||
SecurityEvents.FailedLogin(_logger, "/api/v1/Auth/login", accountExists: user is not null);
|
||||
|
||||
throw new UnauthorizedException("Ongeldige inloggegevens.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace SlpModularCms.Core.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Why the availability gate refused an admin bypass token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A classification rather than the token itself, and the distinction is operationally real:
|
||||
/// <see cref="InvalidSignature"/> means someone is forging tokens, whereas <see cref="Expired"/>
|
||||
/// is almost always an administrator who left a tab open. An alert that cannot tell those apart
|
||||
/// is an alert nobody acts on.
|
||||
/// </remarks>
|
||||
public enum BypassRejectionReason
|
||||
{
|
||||
/// <summary>No Authorization header, or one that is not a bearer token.</summary>
|
||||
Absent,
|
||||
|
||||
/// <summary>Not a readable JWT at all.</summary>
|
||||
Malformed,
|
||||
|
||||
/// <summary>Signature verification failed — the interesting one.</summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>Valid signature, but past its lifetime.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Signed by a different issuer.</summary>
|
||||
WrongIssuer,
|
||||
|
||||
/// <summary>Issued for a different audience.</summary>
|
||||
WrongAudience,
|
||||
|
||||
/// <summary>A genuinely valid token whose holder is not an Owner or Administrator.</summary>
|
||||
NotAdmin
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Core.Observability;
|
||||
|
||||
/// <summary>Stable tag values for the alertable security events.</summary>
|
||||
public static class SecurityEventNames
|
||||
{
|
||||
public const string FailedLogin = "failed_login";
|
||||
public const string AuthorizationDenied = "authorization_denied";
|
||||
public const string MasterApiKeyRejected = "master_api_key_rejected";
|
||||
public const string AdminBypassRejected = "admin_bypass_rejected";
|
||||
public const string RateLimitTriggered = "rate_limit_triggered";
|
||||
public const string MigrationFailure = "migration_failure";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The six security events that alert rules are built on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why source-generated <see cref="LoggerMessageAttribute"/> rather than plain log calls:</b>
|
||||
/// Sentry groups log-derived events by their message. Emitted with interpolation —
|
||||
/// <c>LogWarning($"Login failed for {email}")</c> — every distinct email produces a separate
|
||||
/// Sentry issue, and an alert rule of the form "more than 20 failed logins in five minutes" can
|
||||
/// then never fire, because no single issue ever reaches 20. The feature would look like it
|
||||
/// works: events arrive, they are visible, they are tagged. Only the alerting would silently be
|
||||
/// impossible. A compile-time constant template groups them all into one issue with the variable
|
||||
/// parts as structured fields.
|
||||
///
|
||||
/// Each method also carries a constant <c>SecurityEvent</c> property, which
|
||||
/// <c>SecurityEventProcessor</c> promotes to a Sentry tag so that alert rules filter on
|
||||
/// <c>security_event:failed_login</c> rather than on message text. Matching on message text
|
||||
/// would break the day someone improves the wording — silently, because a rule that matches
|
||||
/// nothing looks exactly like a rule with nothing to match.
|
||||
///
|
||||
/// All six sit at <c>Warning</c> or above by construction, so they cross the Sentry event
|
||||
/// threshold rather than depending on a coincidence of configuration.
|
||||
/// </remarks>
|
||||
public static partial class SecurityEvents
|
||||
{
|
||||
public const int FailedLoginEventId = 5001;
|
||||
public const int AuthorizationDeniedEventId = 5002;
|
||||
public const int MasterApiKeyRejectedEventId = 5003;
|
||||
public const int AdminBypassRejectedEventId = 5004;
|
||||
public const int RateLimitTriggeredEventId = 5005;
|
||||
public const int MigrationFailureEventId = 5006;
|
||||
|
||||
/// <summary>
|
||||
/// Repeated occurrences suggest an attack or a forgotten password. Carries whether the
|
||||
/// account exists — useful for telling the two apart — but never the password, the attempted
|
||||
/// password, or the full address.
|
||||
/// </summary>
|
||||
public static void FailedLogin(ILogger logger, string endpoint, bool accountExists) =>
|
||||
FailedLoginCore(logger, SecurityEventNames.FailedLogin, endpoint, accountExists);
|
||||
|
||||
/// <summary>Someone reached an endpoint they lack rights for.</summary>
|
||||
public static void AuthorizationDenied(ILogger logger, string endpoint, string? requiredPolicy) =>
|
||||
AuthorizationDeniedCore(logger, SecurityEventNames.AuthorizationDenied, endpoint, requiredPolicy ?? "(unnamed)");
|
||||
|
||||
/// <summary>
|
||||
/// Ambiguous by nature: an intruder, <b>or</b> a key ring that has become unreadable. The
|
||||
/// data-durability work exists to make the second cause impossible, but if it ever happens
|
||||
/// this is the first sign of it.
|
||||
/// </summary>
|
||||
public static void MasterApiKeyRejected(ILogger logger, string endpoint, string callingHost) =>
|
||||
MasterApiKeyRejectedCore(logger, SecurityEventNames.MasterApiKeyRejected, endpoint, callingHost);
|
||||
|
||||
/// <summary>
|
||||
/// Only became a meaningful signal once the gate started validating signatures: before that,
|
||||
/// a forged token succeeded silently.
|
||||
/// </summary>
|
||||
public static void AdminBypassRejected(ILogger logger, string path, BypassRejectionReason reason) =>
|
||||
AdminBypassRejectedCore(logger, SecurityEventNames.AdminBypassRejected, path, reason);
|
||||
|
||||
/// <summary>Brute-force pressure. Without this, a 429 leaves no trace anywhere.</summary>
|
||||
public static void RateLimitTriggered(ILogger logger, string limiterName, string endpoint) =>
|
||||
RateLimitTriggeredCore(logger, SecurityEventNames.RateLimitTriggered, limiterName, endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Not a security event, but the one event in this system that needs immediate attention —
|
||||
/// and the process is about to exit, so it must be flushed before it does.
|
||||
/// </summary>
|
||||
public static void MigrationFailure(ILogger logger, Exception exception, int attempts) =>
|
||||
MigrationFailureCore(logger, exception, SecurityEventNames.MigrationFailure, attempts);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = FailedLoginEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event {SecurityEvent}: login failed on {Endpoint} (account exists: {AccountExists})")]
|
||||
private static partial void FailedLoginCore(ILogger logger, string securityEvent, string endpoint, bool accountExists);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = AuthorizationDeniedEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event {SecurityEvent}: authorization denied on {Endpoint} (required policy: {RequiredPolicy})")]
|
||||
private static partial void AuthorizationDeniedCore(ILogger logger, string securityEvent, string endpoint, string requiredPolicy);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = MasterApiKeyRejectedEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event {SecurityEvent}: master API key rejected on {Endpoint} from {CallingHost}")]
|
||||
private static partial void MasterApiKeyRejectedCore(ILogger logger, string securityEvent, string endpoint, string callingHost);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = AdminBypassRejectedEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event {SecurityEvent}: admin bypass rejected on {Path} (reason: {Reason})")]
|
||||
private static partial void AdminBypassRejectedCore(ILogger logger, string securityEvent, string path, BypassRejectionReason reason);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = RateLimitTriggeredEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event {SecurityEvent}: rate limit {LimiterName} triggered on {Endpoint}")]
|
||||
private static partial void RateLimitTriggeredCore(ILogger logger, string securityEvent, string limiterName, string endpoint);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = MigrationFailureEventId,
|
||||
Level = LogLevel.Critical,
|
||||
Message = "Security event {SecurityEvent}: database migration failed after {Attempts} attempt(s)")]
|
||||
private static partial void MigrationFailureCore(ILogger logger, Exception exception, string securityEvent, int attempts);
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Sentry.AspNetCore" Version="6.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+4
-3
@@ -1,9 +1,10 @@
|
||||
using FluentAssertions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
@@ -111,7 +112,7 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = "Bearer owner-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin("Bearer owner-token").Returns(true);
|
||||
_adminTokenValidator.Validate("Bearer owner-token").Returns(new AdminTokenResult(true, default));
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
|
||||
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
@@ -127,7 +128,7 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = "Bearer user-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
|
||||
|
||||
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
@@ -9,6 +9,7 @@ using NSubstitute;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
@@ -145,7 +146,7 @@ public class AvailabilityMiddlewareTests
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = "Bearer some-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin("Bearer some-token").Returns(true);
|
||||
_adminTokenValidator.Validate("Bearer some-token").Returns(new AdminTokenResult(true, default));
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
@@ -159,7 +160,7 @@ public class AvailabilityMiddlewareTests
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = "Bearer some-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
@@ -173,7 +174,7 @@ public class AvailabilityMiddlewareTests
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,7 +17,7 @@ public class MasterControllerTests
|
||||
public MasterControllerTests()
|
||||
{
|
||||
_svc = Substitute.For<IMasterAvailabilityService>();
|
||||
_controller = new MasterController(_svc);
|
||||
_controller = new MasterController(_svc, NullLogger<MasterController>.Instance);
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using SlpModularCms.Modules.Availability.Models;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
@@ -9,36 +11,60 @@ namespace SlpModularCms.Modules.Availability.Controllers;
|
||||
public class MasterController : ControllerBase
|
||||
{
|
||||
private readonly IMasterAvailabilityService _svc;
|
||||
private readonly ILogger<MasterController> _logger;
|
||||
|
||||
public MasterController(IMasterAvailabilityService svc) => _svc = svc;
|
||||
public MasterController(IMasterAvailabilityService svc, ILogger<MasterController> logger)
|
||||
{
|
||||
_svc = svc;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request)
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
if (string.IsNullOrEmpty(apiKey)) return RejectKey();
|
||||
|
||||
var success = await _svc.RegisterAsync(request.MasterUrl, apiKey);
|
||||
return success ? Ok() : Unauthorized();
|
||||
return success ? Ok() : RejectKey();
|
||||
}
|
||||
|
||||
[HttpPost("status")]
|
||||
public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request)
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
if (string.IsNullOrEmpty(apiKey)) return RejectKey();
|
||||
|
||||
var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage);
|
||||
return success ? Ok() : Unauthorized();
|
||||
return success ? Ok() : RejectKey();
|
||||
}
|
||||
|
||||
[HttpGet("registered-url")]
|
||||
public async Task<IActionResult> GetRegisteredUrl()
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
if (string.IsNullOrEmpty(apiKey)) return RejectKey();
|
||||
|
||||
var url = await _svc.GetRegisteredUrlAsync(apiKey);
|
||||
return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized();
|
||||
return url is not null ? Ok(new { MasterUrl = url }) : RejectKey();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the rejection and returns 401. Never logs the key or any part of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This event is ambiguous by nature: it means either an intruder, or that the Data
|
||||
/// Protection key ring has become unreadable so a legitimate master can no longer be
|
||||
/// recognised. The durability work exists to make the second cause impossible, but if it ever
|
||||
/// happens this is the first sign of it — and the two need telling apart quickly.
|
||||
/// </remarks>
|
||||
private IActionResult RejectKey()
|
||||
{
|
||||
SecurityEvents.MasterApiKeyRejected(
|
||||
_logger,
|
||||
Request.Path,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
|
||||
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Middleware;
|
||||
@@ -109,6 +110,17 @@ public class AvailabilityMiddleware
|
||||
/// </remarks>
|
||||
private bool IsAdminBypass(HttpContext context)
|
||||
{
|
||||
return _adminTokenValidator.IsVerifiedAdmin(context.Request.Headers.Authorization.ToString());
|
||||
var result = _adminTokenValidator.Validate(context.Request.Headers.Authorization.ToString());
|
||||
|
||||
// A rejected bypass only became a meaningful signal once the gate started verifying
|
||||
// signatures: before that, a forged token succeeded silently. An absent header is not
|
||||
// reported — every anonymous request to a disabled instance has one, so reporting it
|
||||
// would drown the cases that matter.
|
||||
if (!result.IsVerifiedAdmin && result.Reason != BypassRejectionReason.Absent)
|
||||
{
|
||||
SecurityEvents.AdminBypassRejected(_logger, context.Request.Path, result.Reason);
|
||||
}
|
||||
|
||||
return result.IsVerifiedAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,7 +15,7 @@ public class SlaveStatusControllerTests
|
||||
|
||||
public SlaveStatusControllerTests()
|
||||
{
|
||||
_controller = new SlaveStatusController(_service);
|
||||
_controller = new SlaveStatusController(_service, NullLogger<SlaveStatusController>.Instance);
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using SlpModularCms.Modules.Master.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Master.Controllers;
|
||||
@@ -14,17 +16,30 @@ namespace SlpModularCms.Modules.Master.Controllers;
|
||||
[ApiController]
|
||||
[Route("SlaveStatus")]
|
||||
[AllowAnonymous]
|
||||
public class SlaveStatusController(ICmsInstanceService service) : ControllerBase
|
||||
public class SlaveStatusController(
|
||||
ICmsInstanceService service,
|
||||
ILogger<SlaveStatusController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
if (string.IsNullOrEmpty(apiKey)) return RejectKey();
|
||||
|
||||
var result = await service.GetStatusForApiKeyAsync(apiKey);
|
||||
if (result is null) return Unauthorized();
|
||||
if (result is null) return RejectKey();
|
||||
|
||||
return Ok(new { result.IsAvailable, result.DisableMessage });
|
||||
}
|
||||
|
||||
/// <summary>Reports the rejection and returns 401. Never logs the key or any part of it.</summary>
|
||||
private IActionResult RejectKey()
|
||||
{
|
||||
SecurityEvents.MasterApiKeyRejected(
|
||||
logger,
|
||||
Request.Path,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
|
||||
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user