fix(mesh): Batch B review fixes — bounded store read, cache poison guard, test hardening

This commit is contained in:
Joseph Doherty
2026-08-06 04:10:03 -04:00
parent 68a416e4fc
commit 24e55d3395
7 changed files with 314 additions and 68 deletions
@@ -45,9 +45,18 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <para>
/// <b>All three denials return the same status text</b>, unlike the sibling interceptors,
/// which distinguish "no key configured" from "authentication failed". Here that distinction
/// would be an enumeration oracle: the caller supplies the cluster id, so a distinguishable
/// "no key for that cluster" reply would let an unauthenticated caller map which clusters
/// central holds keys for. The operator-facing detail goes to the log instead.
/// would be a <i>content</i> oracle: the caller supplies the cluster id, so a reply that said
/// "no key for that cluster" would let an unauthenticated caller read central's cluster set
/// straight off the wire. The operator-facing detail goes to the log instead.
/// </para>
/// <para>
/// <b>That is not yet full enumeration resistance, and should not be read as such.</b> A
/// timing oracle survives it: an unknown cluster misses the provider's cache and pays a store
/// read on every probe, while a known one is answered from memory after the first. Closing
/// that needs the lookup to be refused before it reaches the store for any cluster central
/// does not know — which is the Phase 1A known-cluster-set gate, where central learns its
/// cluster set from the DB. Until then, uniform text buys content-level indistinguishability
/// only.
/// </para>
/// <para>
/// <b>Exactly one public constructor.</b> <c>Grpc.AspNetCore</c> silently stops invoking an
@@ -58,9 +67,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
public sealed class CentralCommandAuthInterceptor : Interceptor
{
private const string ServicePrefix = "/mesh_transport.v1.CentralCommandService/";
private const string AuthorizationHeader = "authorization";
private const string ClusterHeader = "x-otopcua-cluster";
private const string BearerPrefix = "Bearer ";
private readonly IMeshPskProvider _pskProvider;
private readonly ILogger<CentralCommandAuthInterceptor> _logger;
@@ -131,7 +138,7 @@ public sealed class CentralCommandAuthInterceptor : Interceptor
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
return;
var clusterId = ExtractHeader(context.RequestHeaders, ClusterHeader);
var clusterId = GrpcRequestHeaders.First(context.RequestHeaders, ClusterHeader);
if (string.IsNullOrWhiteSpace(clusterId))
{
_logger.LogWarning(
@@ -158,7 +165,7 @@ public sealed class CentralCommandAuthInterceptor : Interceptor
"Central command authentication failed."));
}
var presented = ExtractBearerToken(context.RequestHeaders);
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
if (presented is null || !FixedTimeEquals(presented, expected))
{
_logger.LogWarning(
@@ -172,27 +179,6 @@ public sealed class CentralCommandAuthInterceptor : Interceptor
}
}
private static string? ExtractBearerToken(Metadata headers)
{
var value = ExtractHeader(headers, AuthorizationHeader);
return value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)
? value[BearerPrefix.Length..]
: null;
}
private static string? ExtractHeader(Metadata headers, string name)
{
// gRPC lowercases header keys on the wire; compare case-insensitively anyway so a hand-built
// Metadata in a test behaves the same as a real request.
foreach (var entry in headers)
{
if (string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
return entry.Value;
}
return null;
}
private static bool FixedTimeEquals(string presented, string expected)
=> CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));