fix(logging): fail-closed bearer redaction; hoist per-request logger creation
This commit is contained in:
+27
-37
@@ -84,42 +84,32 @@ The names match the MXAccess command list in `gateway.md` exactly. `Write` and `
|
||||
|
||||
### API key redaction
|
||||
|
||||
`RedactApiKey` is built around the `mxgw_` API key format issued by the gateway. It preserves the bearer scheme and the key id segment so that operators can correlate a log entry to a specific principal, but always strips the secret tail:
|
||||
`RedactClientIdentity` is the single redaction path for identity-bearing values; `RedactApiKey` is a
|
||||
name-preserving alias for it. Redaction **fails closed**: the only value that survives with any of its
|
||||
content is a gateway-issued `mxgw_<key-id>_<secret>` key, whose key id is kept so operators can
|
||||
correlate a log entry to a specific principal.
|
||||
|
||||
```csharp
|
||||
public static string? RedactApiKey(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
return authorizationHeader;
|
||||
}
|
||||
| Input | Output | Why |
|
||||
|-------|--------|-----|
|
||||
| `Bearer mxgw_operator01_super-secret` | `Bearer mxgw_operator01_[redacted]` | Recognized gateway key; key id identifies the principal |
|
||||
| `Bearer eyJhbGciOi…` (any foreign token) | `Bearer [redacted]` | Structure is unknown, so the whole credential goes |
|
||||
| `Basic dXNlcjpwYXNz` | `Basic [redacted]` | Same, for any recognized scheme |
|
||||
| `Bearer mxgw_operator01` (no secret separator) | `Bearer mxgw_[redacted]` | No trustworthy key-id boundary |
|
||||
| `Bearer` (scheme only), `anonymous`, `some junk` | `[redacted]` | No scheme/credential split that can be trusted |
|
||||
| `null`, `""`, whitespace | unchanged | Nothing to redact |
|
||||
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RedactedValue;
|
||||
}
|
||||
A scheme word survives only when it is one of the recognized authorization schemes (`Bearer`,
|
||||
`Basic`, `Digest`, `Negotiate`, `NTLM`, `ApiKey`, `Token`). An unrecognized leading word is as likely
|
||||
to be credential material as it is to be a scheme, so it is dropped along with the rest. The key id is
|
||||
also dropped when it runs longer than 64 characters, which no issued key id does — a long run before
|
||||
the first `_` is secret material, not an identifier.
|
||||
|
||||
string token = authorizationHeader[bearerPrefix.Length..].Trim();
|
||||
The parse is span-based (no regex, no `Split` allocation): the value is split once at the first space,
|
||||
and the key id is read up to the first `_` of the remainder.
|
||||
|
||||
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}";
|
||||
}
|
||||
```
|
||||
|
||||
The split uses `count: 3` because the secret portion may itself contain underscores; only the first two segments (`mxgw` and the key id) are kept verbatim. Authorization headers that are not bearer tokens are reduced to `[redacted]` rather than passed through, since the gateway cannot reason about their structure.
|
||||
|
||||
`RedactClientIdentity` is the entry point used by `GatewayLogScope` and `DashboardRedactor`. It only invokes `RedactApiKey` when the input contains the `mxgw_` marker, leaving non-key identities (for example, Windows account names) untouched.
|
||||
The consequence for callers is that a non-key identity (for example a Windows account name) reaching
|
||||
`RedactClientIdentity` is now replaced rather than passed through. `DashboardRedactor` routes only
|
||||
values containing the `mxgw_` marker here, so dashboard display names are unaffected.
|
||||
|
||||
### Command value redaction
|
||||
|
||||
@@ -160,12 +150,12 @@ public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicatio
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
ILogger logger = app.ApplicationServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("MxGateway.Request");
|
||||
|
||||
return app.Use(async (context, next) =>
|
||||
{
|
||||
ILogger logger = context.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("ZB.MOM.WW.MxGateway.Request");
|
||||
|
||||
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
||||
SessionId: ReadHeader(context, SessionIdHeaderName),
|
||||
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
|
||||
@@ -190,7 +180,7 @@ The scope is keyed off four custom headers and the standard `authorization` head
|
||||
|
||||
The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send.
|
||||
|
||||
The logger category is `ZB.MOM.WW.MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories.
|
||||
The logger category is `MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories. The logger is resolved once at registration rather than per request: the category is fixed, so a per-request `IServiceProvider` resolve and `ILoggerFactory.CreateLogger` (which takes the factory lock) bought nothing. Scope construction itself stays unconditional — gating it on `ILogger.IsEnabled` would drop scope state for providers and scope consumers registered after startup.
|
||||
|
||||
### Pipeline ordering
|
||||
|
||||
|
||||
Reference in New Issue
Block a user