fix(security): close Theme 7 — 8 secrets / redaction / append-only findings

Security-sensitive batch, handled main-thread for careful judgment on
secret-leak and pepper-bypass paths.

Secret leak / pepper bypass:
- CD-016 (pepper bypass): InboundApiRepository's GetApiKeyByValueAsync no
  longer hashes the candidate with the unpeppered ApiKeyHasher.Default —
  ctor takes a lazy Func<IApiKeyHasher> accessor (lazy so test composition
  roots without a pepper still bring up the repository), and the DI
  registration wires sp.GetService<IApiKeyHasher>() so the production
  peppered hasher matches the stored KeyHash. Regression test asserts
  positive (peppered roundtrip) AND negative (Default hasher misses the
  same key — proving the lookup uses the injected hasher).
- MgmtSvc-020 (SMTP credential leak): UpdateSmtpConfig/ListSmtpConfigs
  now project through SmtpConfigPublicShape so the response payload and
  audit-row afterState never carry the Credentials field — only a
  HasCredentials bool. The SMTP password / OAuth2 client secret no
  longer leaves the Admin-only UpdateSmtpConfig boundary the caller
  already supplied it to.

Redaction:
- AuditLog-008 (test-fixture under-redact): new
  SafeDefaultAuditPayloadFilter (stateless singleton) does HTTP header
  redaction for the always-sensitive defaults (Authorization, X-Api-Key,
  Cookie, Set-Cookie). FallbackAuditWriter, CentralAuditWriter, and
  AuditLogIngestActor (both ingest paths) default to it instead of null
  — composition roots that bypass AddAuditLog can no longer write
  unredacted auth headers to the audit store.
- NotifService-025 (over-mask): CredentialRedactor.Scrub now only masks
  the last colon-separated component (password / clientSecret) AND only
  if it's >= 12 chars (typical password heuristic). Short user names
  like "root" no longer become global redaction tokens that eat unrelated
  diagnostic text. The full packed string is always masked regardless of
  length. 3 new negative tests pin the no-over-mask contract.

Audit-row correctness / fail-loud:
- InboundAPI-025: Program.cs UseWhen predicate now excludes /api/audit,
  /api/management, /api/centralui, /api/script-analysis AND requires POST
  — the AuditWriteMiddleware no longer emits spurious ApiInbound rows
  for audit-log query/export endpoints (write-on-read recursion broken).
- ESG-021: ApplyAuth now logs Warning (not silent) on empty
  AuthConfiguration for apikey/basic, unknown AuthType, and malformed
  Basic config. AuthConfiguration value NEVER logged. AuthType=none
  remains silent (documented unauthenticated sentinel).
- Security-021: AddSecurity now logs a startup Warning when
  RequireHttpsCookie=false — an HTTP-only deployment that previously
  transmitted the cookie-embedded JWT silently in cleartext is now
  audible in the log.

Defensive:
- CD-021: SwitchOutPartitionAsync's monthBoundary format string now
  yyyy-MM-dd HH:mm:ss.fffffff (datetime2(7) precision) so a future
  sub-second / non-midnight boundary doesn't silently round to the
  wrong partition.

Plus reconciled stale per-module Open-findings counters that had drifted
from earlier sessions (AuditLog, CD, ESG, IAPI, MgmtSvc, NotifService,
Security).

Build clean; all affected test projects green (Host 208, ConfigDB 242,
ESG 69, IAPI 151, MgmtSvc 100, NotifService 55, Security 85, AuditLog
247/248 — 1 pre-existing date-sensitive integration test flake on
PartitionPurgeTests, unrelated). README regenerated: 46 open (was 54).
This commit is contained in:
Joseph Doherty
2026-05-28 08:04:10 -04:00
parent 55f46e7c92
commit 46cb6965ac
22 changed files with 500 additions and 77 deletions
@@ -164,8 +164,12 @@ public class AuditLogIngestActor : ReceiveActor
// is a silent no-op.
// Filter BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. Filter
// is contract-bound to never throw; null = pass-through.
var filtered = filter?.Apply(evt) ?? evt;
// is contract-bound to never throw. AuditLog-008: a null
// filter (test composition root, no IAuditPayloadFilter
// registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filtered = safeFilter.Apply(evt);
var ingested = filtered with { IngestedAtUtc = nowUtc };
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId);
@@ -239,8 +243,10 @@ public class AuditLogIngestActor : ReceiveActor
// Filter the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are filterable; SiteCalls
// carries operational state only (status, retry count) and
// is left untouched.
var filteredAudit = filter?.Apply(entry.Audit) ?? entry.Audit;
// is left untouched. AuditLog-008: null filter falls back
// to SafeDefault so header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filteredAudit = safeFilter.Apply(entry.Audit);
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
@@ -41,7 +41,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter? _filter;
private readonly IAuditPayloadFilter _filter;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
@@ -80,7 +80,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_filter = filter;
// AuditLog-008: never default to null — over-redact instead.
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real filter still scrubs Authorization / X-Api-Key / Cookie /
// Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity;
}
@@ -99,9 +104,11 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws"; the null-coalesce keeps the
// M4 test composition roots (no filter passed) working unchanged.
var filtered = _filter?.Apply(evt) ?? evt;
// filter contract is "never throws". AuditLog-008: _filter is now
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
// redaction always runs even in composition roots that omit the
// real filter.
var filtered = _filter.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its
@@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.AuditLog.Payload;
/// <summary>
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
/// HTTP header redaction for the always-sensitive defaults
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
/// redaction, or truncation — those stages need
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
/// over-redact safely, never throw, never miss a header that's on the
/// default sensitive list.
/// </summary>
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
{
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
private static readonly string[] DefaultHeaderRedactList =
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
private static readonly Regex HeaderRegex = new(
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private SafeDefaultAuditPayloadFilter() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
ArgumentNullException.ThrowIfNull(rawEvent);
try
{
return rawEvent with
{
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
};
}
catch
{
// Over-redact: drop both summaries entirely so a malformed parse
// path never leaks the original. The contract is "never throw."
return rawEvent with
{
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
};
}
}
private static string? RedactHeaders(string? summary)
{
if (string.IsNullOrEmpty(summary)) return summary;
return HeaderRegex.Replace(summary, m =>
{
var name = m.Groups["name"].Value;
foreach (var sensitive in DefaultHeaderRedactList)
{
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
{
return $"{name}: [REDACTED]";
}
}
return m.Value;
});
}
}
@@ -31,7 +31,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger<FallbackAuditWriter> _logger;
private readonly IAuditPayloadFilter? _filter;
private readonly IAuditPayloadFilter _filter;
private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary>
@@ -60,7 +60,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_filter = filter; // null = no-op pass-through; see WriteAsync.
// AuditLog-008: never default to a null filter — over-redact instead.
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
// redaction with the hard-coded sensitive defaults (Authorization,
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
// doesn't bind the real options never persists those headers
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
}
/// <inheritdoc />
@@ -72,9 +79,10 @@ public sealed class FallbackAuditWriter : IAuditWriter
// and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The filter contract is
// "MUST NOT throw"; the null-coalesce keeps test composition roots
// that don't wire a filter working unchanged.
var filtered = _filter?.Apply(evt) ?? evt;
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
// to SafeDefaultAuditPayloadFilter so header redaction is always
// applied even in composition roots that don't wire the real filter).
var filtered = _filter.Apply(evt);
try
{
@@ -198,8 +198,12 @@ VALUES
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
// settings.
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss");
// settings. CD-021: use datetime2(7) precision (.fffffff) so a future
// non-midnight or sub-second boundary doesn't silently round to the
// wrong partition (today the migration only seeds at T00:00:00 exactly,
// but the format string is on the boundary value's own contract — match
// it to the column precision rather than to the current seed pattern).
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Two-statement batch: the first SELECT samples the per-partition row
// count BEFORE the dance so we can report it back to the purge actor;
@@ -10,16 +10,38 @@ namespace ScadaLink.ConfigurationDatabase.Repositories;
public class InboundApiRepository : IInboundApiRepository
{
private readonly ScadaLinkDbContext _context;
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
// actually called — Central/Host startup composition roots that never call
// this method (the production ApiKeyValidator deliberately doesn't) get to
// bring InboundApiRepository up without forcing every test to wire a
// throw-away pepper into InboundApiOptions.
private readonly Func<IApiKeyHasher> _hasherAccessor;
private readonly ILogger<InboundApiRepository> _logger;
/// <summary>
/// Initializes a new instance of the InboundApiRepository class.
/// </summary>
/// <param name="context">The database context for accessing inbound API data.</param>
/// <param name="hasherAccessor">
/// CD-016: factory that returns the API-key hasher used to digest a candidate
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
/// Resolution is deferred to first call so a composition root that doesn't
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
/// no pepper is configured) can still bring up the repository for callers that
/// don't touch the value-lookup path. Defaults to a factory returning
/// <see cref="ApiKeyHasher.Default"/>; production wires
/// <c>sp =&gt; sp.GetRequiredService&lt;IApiKeyHasher&gt;()</c> via DI so the
/// lookup uses the same peppered digest as the production write path.
/// </param>
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null)
public InboundApiRepository(
ScadaLinkDbContext context,
Func<IApiKeyHasher>? hasherAccessor = null,
ILogger<InboundApiRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
}
@@ -34,7 +56,13 @@ public class InboundApiRepository : IInboundApiRepository
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
{
var keyHash = ApiKeyHasher.Default.Hash(keyValue);
// CD-016: hash the candidate with the DI-provided peppered hasher so this
// lookup matches keys whose stored KeyHash was produced by the production
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
// silently return null for every real key on any peppered deployment.
// Resolution is deferred until this method is actually called so the
// pepper-validating factory doesn't fire during startup composition.
var keyHash = _hasherAccessor().Hash(keyValue);
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -53,7 +54,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
// is actually called, but composition roots that never call it (and may
// not register IApiKeyHasher at all) still bring up the repository.
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
sp.GetRequiredService<ScadaLinkDbContext>(),
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
logger: sp.GetService<ILogger<InboundApiRepository>>()));
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();
@@ -464,12 +464,29 @@ public class ExternalSystemClient : IExternalSystemClient
return url;
}
private static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
{
if (string.IsNullOrEmpty(system.AuthConfiguration))
return;
// ESG-021: distinguish "intentionally unauthenticated" (AuthType = none)
// from "AuthConfiguration is missing or empty for a type that requires it"
// (deployment glitch, decryption failure, operator typo). The unauthenticated
// case is silent; the requires-creds-but-empty case logs a Warning so an
// operator debugging a recurring 401 sees the cause inside ScadaLink instead
// of having to read the remote system's logs. The value of AuthConfiguration
// is NEVER logged.
var authType = system.AuthType?.Trim().ToLowerInvariant() ?? string.Empty;
switch (system.AuthType.ToLowerInvariant())
if (string.IsNullOrEmpty(system.AuthConfiguration))
{
if (authType is "apikey" or "basic")
{
_logger.LogWarning(
"ApplyAuth: External system '{System}' has AuthType '{AuthType}' but AuthConfiguration is empty; request will be sent without an auth header.",
system.Name, system.AuthType);
}
return;
}
switch (authType)
{
case "apikey":
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
@@ -493,6 +510,26 @@ public class ExternalSystemClient : IExternalSystemClient
Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
}
else
{
// ESG-021: malformed Basic config (no ':' separator) means the
// request goes out with no Authorization header. Warn so the
// failure mode is visible inside ScadaLink.
_logger.LogWarning(
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed (expected 'username:password'); request will be sent without an Authorization header.",
system.Name);
}
break;
case "none":
// Documented sentinel for unauthenticated systems — silent by design.
break;
default:
// ESG-021: unknown AuthType silently fell through here before. Warn.
_logger.LogWarning(
"ApplyAuth: External system '{System}' has unknown AuthType '{AuthType}'; request will be sent without an auth header. Allowed values: apikey, basic, none.",
system.Name, system.AuthType);
break;
}
}
+16 -1
View File
@@ -195,8 +195,23 @@ try
// is responsible for stashing the resolved API key name on
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
// in-handler API key validation succeeds.
// InboundAPI-025: scope the audit middleware to the inbound API method
// route (/api/{methodName}) and explicitly exclude the management/audit
// sub-trees that share the /api prefix. Without these exclusions the
// middleware would emit a spurious ApiInbound audit row for every
// /api/audit/query and /api/audit/export call (and would treat audit-log
// reads as inbound script invocations — recursive write-on-read). The
// POST-only filter rules out the GET routes on /api/audit, /api/centralui,
// /api/script-analysis even if a future route is added under those
// prefixes with the same verb; the explicit prefix excludes still belt-
// and-brace POST-y additions there.
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api"),
ctx => ctx.Request.Path.StartsWithSegments("/api")
&& !ctx.Request.Path.StartsWithSegments("/api/audit")
&& !ctx.Request.Path.StartsWithSegments("/api/centralui")
&& !ctx.Request.Path.StartsWithSegments("/api/script-analysis")
&& !ctx.Request.Path.StartsWithSegments("/api/management")
&& HttpMethods.IsPost(ctx.Request.Method),
branch => branch.UseAuditWriteMiddleware());
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
@@ -1135,10 +1135,36 @@ public class ManagementActor : ReceiveActor
return true;
}
/// <summary>
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
/// boundary via response payloads or audit afterState. Mirrors the
/// ApiKey-projection pattern in HandleListApiKeys / CD-012's fix.
/// </summary>
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
new
{
c.Id,
c.Host,
c.Port,
c.AuthType,
c.FromAddress,
c.TlsMode,
c.ConnectionTimeoutSeconds,
c.MaxConcurrentConnections,
c.MaxRetries,
c.RetryDelay,
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
};
private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
{
var repo = sp.GetRequiredService<INotificationRepository>();
return await repo.GetAllSmtpConfigurationsAsync();
var configs = await repo.GetAllSmtpConfigurationsAsync();
// MgmtSvc-020: project away the Credentials field — read access to this
// list is broader than the Admin-only UpdateSmtpConfig path that owns
// the secret.
return configs.Select(SmtpConfigPublicShape).ToList();
}
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
@@ -1156,8 +1182,12 @@ public class ManagementActor : ReceiveActor
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
return config;
// MgmtSvc-020: audit the credential-free shape — the *fact of* the change
// (and which non-secret fields hold) is observable; the secret value is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmtpConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
return publicShape;
}
// ========================================================================
@@ -24,6 +24,15 @@ public static class CredentialRedactor
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
/// <c>tenantId:clientId:clientSecret</c>. May be null.
/// </param>
/// <summary>
/// NS-025: minimum length for a colon-separated SECRET component to be
/// considered worth masking. Twelve characters is the standard heuristic
/// for "long enough to be a password / client secret"; shorter components
/// (e.g. a 4-char user name like <c>root</c>, or a 7-char "from" alias)
/// would mask too much unrelated diagnostic text if treated as secrets.
/// </summary>
private const int MinSecretLength = 12;
public static string Scrub(string? text, string? credentials)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
@@ -33,16 +42,36 @@ public static class CredentialRedactor
var result = text;
// Mask each individual colon-delimited component (covers user, password,
// tenant, clientId, clientSecret) and the whole packed string. Order longest
// first so a component that is a substring of another is still fully masked.
var parts = credentials.Split(':')
.Where(p => p.Length >= 4)
.Append(credentials)
.Distinct()
.OrderByDescending(p => p.Length);
// NS-025: redact only the obviously-secret slots — the LAST
// colon-separated component (the password in Basic, the client
// secret in OAuth2) and the whole packed string — not the user
// name / tenant id / client id. A short user name like "root" or
// a sender alias like "smtp" no longer becomes a global redaction
// token that eats unrelated path / error text.
var secretsToRedact = new List<string>();
foreach (var part in parts)
// The full packed credential is always the most-sensitive shape.
secretsToRedact.Add(credentials);
// The trailing colon-component is the password / clientSecret slot.
// Only redact it if it's plausibly secret-shaped (>= MinSecretLength).
var parts = credentials.Split(':');
if (parts.Length >= 2)
{
var lastComponent = parts[^1];
if (lastComponent.Length >= MinSecretLength)
{
secretsToRedact.Add(lastComponent);
}
}
// Order longest first so a secret that is a substring of the packed
// string is still fully masked.
var ordered = secretsToRedact
.Distinct()
.OrderByDescending(s => s.Length);
foreach (var part in ordered)
{
result = result.Replace(part, Mask, StringComparison.Ordinal);
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ScadaLink.Security;
@@ -56,7 +57,7 @@ public static class ServiceCollectionExtensions
// session window. Bound here via PostConfigure so SecurityOptions
// (configured by the Host after AddSecurity) is honoured.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<SecurityOptions>>((cookieOptions, securityOptions) =>
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
{
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
@@ -69,6 +70,18 @@ public static class ServiceCollectionExtensions
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
// Security-021: when the operator opts out of HTTPS-only cookies,
// log a Warning so an HTTP-only deployment is at least audible in
// the startup log. The cookie carries the embedded JWT bearer
// credential — over plain HTTP that travels in cleartext on every
// request. The default is true; this branch fires only on an
// explicit opt-out (typically the dev Docker cluster).
if (!securityOptions.Value.RequireHttpsCookie)
{
loggerFactory.CreateLogger("ScadaLink.Security").LogWarning(
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
}
});
services.AddScadaLinkAuthorization();