fix(logging): fail-closed bearer redaction; hoist per-request logger creation
This commit is contained in:
@@ -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_<key-id>_</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><key-id>_[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>
|
||||
|
||||
+8
-4
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user