fix(auth): #543 whitespace-only configured key fails closed in the three pre-existing gRPC auth interceptors

TelemetryStreamAuthInterceptor, ConfigServeAuthInterceptor and
LocalDbSyncAuthInterceptor all decided "is a key configured?" with
string.IsNullOrEmpty. A whitespace-only configured value therefore passed
that guard and fell through to the constant-time comparison — where it is
satisfied by a whitespace-only bearer token. The gate would be open to
anyone who guessed a blank credential.

This backports the check the two NEW mesh interceptors already use:
MeshCommandAuthInterceptor (and its central-side clone) validate with
string.IsNullOrWhiteSpace, so the four/five planes now answer the same way
to the same misconfiguration. Nothing else changes — the denial status
codes (PermissionDenied / Unauthenticated / PermissionDenied respectively)
and every denial message are untouched, and no key material is logged.

No deployment configures a whitespace key: the docker-dev rig and
production both set real keys, so this is a fail-closed hardening of an
unreachable-today misconfiguration rather than a live exposure.

Tests: each of the three test files gains a ..._WithBlankKeyConfigured_IsDenied
fact mirroring MeshCommandAuthInterceptorTests.ExecuteMethod_WithBlankPskConfigured_IsDenied
(configured key "   ", presented "Bearer    "), verified red before the fix.
ConfigServeAuthInterceptor and LocalDbSyncAuthInterceptor also gain the
HasExactlyOnePublicConstructor pin their two siblings already had —
Grpc.AspNetCore silently stops invoking an interceptor with more than one
public constructor, which would disable auth without failing anything.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-06 17:14:36 -04:00
parent 97854a074e
commit f10f4c3754
6 changed files with 68 additions and 3 deletions
@@ -110,7 +110,9 @@ public sealed class ConfigServeAuthInterceptor : Interceptor
return;
var expected = _options.Value.ApiKey;
if (string.IsNullOrEmpty(expected))
// Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be
// satisfied by an all-blank bearer token, which is no gate at all.
if (string.IsNullOrWhiteSpace(expected))
{
_logger.LogWarning(
"Rejected a config-serve call to {Method}: no ConfigServe:ApiKey is configured, so the " +
@@ -115,7 +115,9 @@ public sealed class LocalDbSyncAuthInterceptor : Interceptor
return;
var expected = _options.Value.ApiKey;
if (string.IsNullOrEmpty(expected))
// Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be
// satisfied by an all-blank bearer token, which is no gate at all.
if (string.IsNullOrWhiteSpace(expected))
{
_logger.LogWarning(
"Rejected a LocalDb sync call to {Method}: no LocalDb:Replication:ApiKey is configured, " +
@@ -117,7 +117,9 @@ public sealed class TelemetryStreamAuthInterceptor : Interceptor
return;
var expected = _options.Value.ApiKey;
if (string.IsNullOrEmpty(expected))
// Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be
// satisfied by an all-blank bearer token, which is no gate at all.
if (string.IsNullOrWhiteSpace(expected))
{
_logger.LogWarning(
"Rejected a telemetry stream call to {Method}: no Telemetry:ApiKey is configured, so " +