fix(logging): fail-closed bearer redaction; hoist per-request logger creation

This commit is contained in:
Joseph Doherty
2026-08-15 12:17:27 -04:00
parent e2ac5d117a
commit ca34a2d65d
4 changed files with 182 additions and 73 deletions
@@ -15,6 +15,28 @@ public static class GatewayLogRedactor
"WriteSecured2"
};
/// <summary>
/// Authorization schemes whose name may survive redaction. Anything outside this list is
/// dropped whole: an unrecognized leading word is as likely to be credential material as it
/// is to be a scheme, so it is not worth the leak.
/// </summary>
private static readonly string[] KnownAuthorizationSchemes =
[
"Bearer",
"Basic",
"Digest",
"Negotiate",
"NTLM",
"ApiKey",
"Token",
];
/// <summary>Prefix identifying a gateway-issued API key.</summary>
private const string GatewayKeyPrefix = "mxgw_";
/// <summary>Upper bound on a key id kept in the clear; a longer run is treated as secret material.</summary>
private const int MaxKeyIdLength = 64;
/// <summary>
/// Determines whether a command method bears credentials.
/// </summary>
@@ -27,44 +49,24 @@ public static class GatewayLogRedactor
}
/// <summary>
/// Redacts the API key secret portion of a Bearer authorization header.
/// Redacts the credential portion of an authorization header value.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
/// <returns>The header with the secret portion redacted, or the original value when it is null, blank, or not a Bearer header.</returns>
/// <returns>The header with the credential redacted, or the original value when it is null or blank.</returns>
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return authorizationHeader;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return RedactedValue;
}
string token = authorizationHeader[bearerPrefix.Length..].Trim();
if (!token.StartsWith("mxgw_", StringComparison.OrdinalIgnoreCase))
{
return $"{bearerPrefix}{RedactedValue}";
}
string[] tokenParts = token.Split('_', 3, StringSplitOptions.RemoveEmptyEntries);
if (tokenParts.Length < 2)
{
return $"{bearerPrefix}mxgw_{RedactedValue}";
}
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
return RedactClientIdentity(authorizationHeader);
}
/// <summary>
/// Redacts the client identity if it contains an API key.
/// Redacts the credential carried by a client identity. Redaction fails closed: only a
/// gateway-issued API key keeps its <c>mxgw_&lt;key-id&gt;_</c> shape (so operators can tell keys
/// apart in logs), and only a recognized scheme keeps its name. Every other value — a foreign
/// bearer token, a scheme-less string, junk — is replaced whole, because nothing that reaches
/// this method is known to be safe to log.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
/// <returns>The redacted client identity, or the original value when it contains no API key.</returns>
/// <returns>The redacted client identity, or the original value when it is null or blank.</returns>
public static string? RedactClientIdentity(string? clientIdentity)
{
if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -72,9 +74,61 @@ public static class GatewayLogRedactor
return clientIdentity;
}
return clientIdentity.Contains("mxgw_", StringComparison.OrdinalIgnoreCase)
? RedactApiKey(clientIdentity)
: clientIdentity;
ReadOnlySpan<char> value = clientIdentity.AsSpan().Trim();
int separatorIndex = value.IndexOf(' ');
if (separatorIndex < 0)
{
// A single token carries no scheme, so the token itself is the credential.
return RedactedValue;
}
ReadOnlySpan<char> scheme = value[..separatorIndex];
ReadOnlySpan<char> credential = value[(separatorIndex + 1)..].Trim();
if (credential.IsEmpty || !IsKnownAuthorizationScheme(scheme))
{
return RedactedValue;
}
return credential.StartsWith(GatewayKeyPrefix, StringComparison.OrdinalIgnoreCase)
? $"{scheme} {GatewayKeyPrefix}{RedactKeyId(credential)}"
: $"{scheme} {RedactedValue}";
}
/// <summary>
/// Renders the trailing portion of a gateway API key: the key id when the key is well formed,
/// otherwise nothing but the placeholder.
/// </summary>
/// <param name="credential">The credential, known to start with the gateway key prefix.</param>
/// <returns>The <c>&lt;key-id&gt;_[redacted]</c> tail, or just the placeholder.</returns>
private static string RedactKeyId(ReadOnlySpan<char> credential)
{
ReadOnlySpan<char> remainder = credential[GatewayKeyPrefix.Length..];
int secretIndex = remainder.IndexOf('_');
// No separator means no secret boundary to trust, so the whole remainder is treated as secret.
return secretIndex is <= 0 or > MaxKeyIdLength
? RedactedValue
: $"{remainder[..secretIndex]}_{RedactedValue}";
}
/// <summary>
/// Determines whether a leading word is a recognized authorization scheme.
/// </summary>
/// <param name="scheme">The candidate scheme word.</param>
/// <returns><see langword="true"/> when the word may survive redaction; otherwise <see langword="false"/>.</returns>
private static bool IsKnownAuthorizationScheme(ReadOnlySpan<char> scheme)
{
foreach (string knownScheme in KnownAuthorizationSchemes)
{
if (scheme.Equals(knownScheme, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
@@ -24,12 +24,16 @@ public static class GatewayRequestLoggingMiddlewareExtensions
{
ArgumentNullException.ThrowIfNull(app);
// Resolved once at registration: the logger is keyed by category, not by request, so the
// per-request DI resolve and logger-factory lock bought nothing.
ILogger logger = app.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("MxGateway.Request");
return app.Use(async (context, next) =>
{
ILogger logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("MxGateway.Request");
// Scope construction is deliberately unconditional: gating it on IsEnabled would drop
// scope state for providers (and scope consumers) registered after startup.
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
SessionId: ReadHeader(context, SessionIdHeaderName),
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),