SN-2026-019: Notification Keyspace Registration and Validation Redaction
SN-2026-019 covers a class of notification configuration defects fixed in Server 20260804 (162ded343 and 672c07a0f, pgsty/silo#39): notification parser and legacy-migration paths read or wrote KVS configuration keys that were missing from Default*KVS, while CheckValidKeys echoed raw key values in validation errors and FetchEnabledTargets aborted all active notification targets on the first validation failure. In Server 20260903 (f1ba68358), the discrete PostgreSQL and MySQL host/credential keys uncovered by our AST audit were retired in favor of connection_string and dsn_string.
Conclusions first
- Four uncoordinated surfaces drifted apart: In the notification configuration subsystem,
Default*KVS(valid key schema),Help*(help text and secret/sensitive classification),GetNotify*(parser), andSetNotify*(legacy config migration) were maintained by hand without a cross-check. - One invalid target disabled all notification targets:
FetchEnabledTargetsvalidates every notification subsystem sequentially and returns on the first error, leaving the global notification target listnilacross the server. - Validation errors echoed secret values: When
CheckValidKeysrejected an unknown or mistyped key, it formatted the error usingkv.String(), printingkey=value—including credentials or passwords—into logs and admin CLI responses. - AST-enforced invariant: Server
20260804registers the missing NATS and AMQP keys, redacts values fromCheckValidKeyserrors, expandsHasSensitiveKeys, and enforces a compile-time AST test ensuring every key read or written bycmd/config/notifyis registered inDefault*KVS.
A Puzzling Bug Report
The investigation started from a bizarre symptom reported by kuldeep-link11: an operator ran mc admin config set us notify_nats:FITCHECK address="..." subject="..." and received an error complaining about a completely different target (notify_nats:ONE) and two keys (nkey_seed= tls_handshake_first=off) that the operator had never passed on the command line:
Two separate mechanisms combined to produce that confusing error:
- Tokenizer absorption on
mc admin config set:kvFieldsininternal/config/parser.gosplits input tokens by matching only keys registered inDefaultKVS. When a user passes an unregistered key on the command line,kvFieldsdoes not recognize it as a key delimiter and silently absorbs"nkey_seed=..."as part of the preceding key’s value (such assubject). - Legacy migration poisoning on
notify_nats:ONE: Earlier in the deployment’s life, the legacy configuration migrator (SetNotifyNATSincmd/config/notify/legacy.go) had serializednotify_nats:ONEdirectly into the KVS struct—bypassingkvFields—and writtennkey_seedandtls_handshake_firstas discrete KVS keys. When the operator later editednotify_nats:FITCHECK,validateConfigre-validated all stored targets and tripped overnotify_nats:ONE.
One Constant Used for Two Different Namespaces
Inspecting internal/config/notify/target/nats.go immediately revealed a second bug right next to nkey_seed:
When PR minio/minio#19139 added NATS user credentials support in February 2024, NATSUserCredentials was placed inside the Env* constant block with the value "MINIO_NOTIFY_NATS_USER_CREDENTIALS", and then used as both the environment variable name and the KVS key name in DefaultNATSKVS. As a result, setting user_credentials=/path/to/creds via mc admin config set notify_nats was rejected as an unknown key, whereas the literal string MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds was what DefaultNATSKVS expected on disk.
Four Uncoordinated Surfaces
Auditing all notification targets in cmd/config/notify/ showed that every target defines four separate surfaces:
Default*KVS(cmd/config/notify/config.go): the canonical list of valid KVS keys enforced byCheckValidKeys,Help*(cmd/config/notify/help.go): user-facing help metadata andType(Sensitive/Secret),GetNotify*(cmd/config/notify/parse.go): reads KVS keys andMINIO_NOTIFY_*environment variables,SetNotify*(cmd/config/notify/legacy.go): migrates older JSON config structs into KVS pairs.
Across NATS and AMQP, four keys had drifted across those four surfaces:
| Target | Key | Introduced In | Default*KVS |
Help* |
GetNotify* |
SetNotify* |
Failure Mode |
|---|---|---|---|---|---|---|---|
notify_nats |
user_credentials |
#19139 (2024-02) |
Registered as MINIO_NOTIFY_NATS_USER_CREDENTIALS |
Missing | Read MINIO_NOTIFY_NATS_USER_CREDENTIALS |
Wrote MINIO_NOTIFY_NATS_USER_CREDENTIALS |
user_credentials rejected on mc admin config set |
notify_nats |
nkey_seed |
#21231 (2025-05) |
Missing | Missing | Read nkey_seed |
Wrote nkey_seed |
Stored config fails CheckValidKeys |
notify_nats |
tls_handshake_first |
#21008 (2025-02) |
Missing | Present | Read tls_handshake_first |
Wrote tls_handshake_first |
Stored config fails CheckValidKeys |
notify_amqp |
immediate |
Initial KVS migration | Missing | Missing | Read immediate |
Wrote cfg.Immediate into AmqpInternal! |
immediate rejected; SetNotifyAMQP overwrote internal with cfg.Immediate |
Notice the AMQP bug in the last row: SetNotifyAMQP in cmd/config/notify/legacy.go had a copy-paste typo that wrote strconv.FormatBool(cfg.Immediate) under the key target.AmqpInternal and never serialized cfg.Internal at all.
Blast-Radius Amplifier: All-or-Nothing Target Loading
Why did a broken notify_nats:ONE target matter so much? In cmd/config/notify/parse.go, FetchEnabledTargets loads all ten notification subsystems (amqp, elasticsearch, kafka, mqtt, mysql, nats, nsq, postgres, redis, webhook) in sequence. If any subsystem returns a validation error from CheckValidKeys, FetchEnabledTargets returns early before populating the active target map.
Consequently, a single invalid key in an unused notify_nats target causes startup or config reload to leave the global notification target list empty, silently disabling all bucket notifications (such as active Kafka or Webhook targets) until an administrator removes the offending config entry.
We considered changing FetchEnabledTargets to isolate errors per subsystem and keep other targets running, but declined that change: across SILO, configuration validation is intentionally all-or-nothing so that mc admin config set rejects broken configurations rather than partially applying them. Fixing key registration removes the false-positive failures without weakening config transaction semantics.
Fixing Key Registration and Classification
Commit 162ded343 fixes the NATS and AMQP keyspace defects:
- Split key constant from env constant:
target.NATSUserCredentialsis now"user_credentials", andtarget.EnvNATSUserCredentialsis"MINIO_NOTIFY_NATS_USER_CREDENTIALS". - Register all four keys in
DefaultNATSKVSandDefaultAMQPKVS:user_credentials,nkey_seed, andtls_handshake_firstare registered inDefaultNATSKVS, andimmediateis registered inDefaultAMQPKVS. - Complete
HelpNATSandHelpAMQP: Added help entries foruser_credentials,nkey_seed, andimmediate. Bothuser_credentialsandnkey_seedtake local file paths on the server filesystem (just likeclient_key), so we classified them asconfig.Sensitiverather thanconfig.Secret. - Fix
SetNotifyAMQP:SetNotifyAMQPnow writescfg.Internaltotarget.AmqpInternalandcfg.Immediatetotarget.AmqpImmediate.
Narrow Backward Tolerance for Stored NATS Configs
What if a deployment prior to 20260804 had worked around #19139 by storing the literal key MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds in its persisted cluster configuration?
Simply renaming NATSUserCredentials to "user_credentials" would cause CheckValidKeys to reject that persisted configuration on upgrade—triggering the exact FetchEnabledTargets outage described above.
While designing backward tolerance, we uncovered a subtle trap between two identically named functions in internal/config:
- The free function
config.CheckValidKeys(subSys, kvs, defaultKVS, deprecatedKeys...)accepts an optional variadic list of deprecated keys and permits them during load validation. - The method
kvs.CheckValidKeys(subSys, defaultKVS)callsCheckValidKeys(subSys, kvs, defaultKVS)without passing any deprecated keys.
We updated the NATS load path (GetNotifyNATS) to tolerate "MINIO_NOTIFY_NATS_USER_CREDENTIALS" via the deprecated-key list on config.CheckValidKeys, and implemented deterministic precedence when populating UserCredentials:
MINIO_NOTIFY_NATS_USER_CREDENTIALSenvironment variable (highest precedence),user_credentialsKVS key,- Legacy
MINIO_NOTIFY_NATS_USER_CREDENTIALSKVS key (fallback whenuser_credentialsis empty).
Redacting Values in Validation Errors (SN-2026-019)
Before 20260804, both config.CheckValidKeys and KVS.CheckValidKeys built their error string by appending kv.String() (key=value) for every unrecognized key. If an administrator mistyped a secret key name—for example, pasword=Hunter2 or auth_tokn=eyJ...—or if a legacy migrator wrote an unregistered credential key, the error message echoed the cleartext secret into the console response and server error logs.
Commit 162ded343 changes both CheckValidKeys functions to report only the offending key names (kv.Key), never kv.Value.
In commit 672c07a0f, we also audited config.HasSensitiveKeys (internal/config/config.go), which masks secrets when exporting configurations via mc admin config get or generating health-diagnostics bundles. We expanded HasSensitiveKeys to cover all credential key names used across notification, identity, and KMS subsystems (password, auth_token, secret_key, client_secret, nkey_seed, user_credentials, sasl_password, connection_string, and dsn_string).
Enforcing the Invariant with an AST Audit Test
To make sure Default*KVS, parse.go, and legacy.go can never drift apart again, we wrote an AST-based verification test in cmd/config/notify/config_audit_test.go. Using go/ast, the test parses parse.go and legacy.go, resolves every kvs.Get(...) and kvs.Set(...) call back to its string constant definition in internal/config/notify/target, and verifies that every referenced key exists in the corresponding Default*KVS.
While reviewing the audit test itself, we found and closed two blind spots in our initial test implementation:
- Reverse floor assertion: Checking only that
parse.go ⊆ Default*KVSwould pass even if the AST walker broke and extracted zero keys! We added the reverse check requiring every key registered inDefault*KVS(except documented universal keys likeenableandcomment) to be read inparse.goand asserting a minimum key-count floor per subsystem. - Untyped composite literals: In
legacy.go, helper tables constructedconfig.KVS{...}using untyped{Key, Value}composite literals rather than callingkvs.Set(). Extending the AST visitor to inspectconfig.KVScomposite literals immediately surfaced a second bug in the codebase.
What the AST Audit Caught: PostgreSQL and MySQL Discrete Keys
As soon as the AST visitor inspected config.KVS composite literals in legacy.go, it flagged SetNotifyPostgres and SetNotifyMySQL: both functions wrote five discrete keys—host, port, username, password, and database—that were read by GetNotifyPostgres / GetNotifyMySQL (parse.go) when connection_string / dsn_string was empty, yet were completely absent from DefaultPostgresKVS and DefaultMySQLKVS!
In addition, because password was not registered in DefaultPostgresKVS or DefaultMySQLKVS, passing password=... or migrating a legacy PostgreSQL/MySQL config triggered CheckValidKeys and printed the database password in the error message prior to 162ded343.
We handled this in two steps across two releases:
- Short-term fix in Server 20260804 (
672c07a0f): Registeredhost,port,username,password, anddatabaseinDefaultPostgresKVSandDefaultMySQLKVS(and markedpasswordasSecretinHelpPostgres/HelpMySQL) so that existing configurations and the new AST invariant were immediately consistent. - Architectural cleanup in Server 20260903 (
f1ba68358): Having two parallel ways to configure database connections (connection_stringvs. discretehost/port/username/password/databasekeys) causedSetNotifyPostgresandSetNotifyMySQLto overwriteconnection_stringwith empty discrete fields during config round-trips. Server20260903retired the five discrete keys acrossDefault*KVS,Help*,parse.go, andlegacy.go, standardizing exclusively onconnection_string(PostgreSQL) anddsn_string(MySQL).
Declined Changes
- Do not change
kvFieldstokenizer framing in this patch: ChangingkvFieldsto split on arbitrarykey=valuetokens rather than knownDefaultKVSkeys would alter how unquoted spaces inside values (such as SQL queries or subjects) are parsed. WithDefault*KVSnow verified by the AST audit, valid keys are always recognized bykvFields. - Do not weaken
FetchEnabledTargetsfail-fast validation: As explained in Blast-Radius Amplifier, configuration validation remains atomic across all subsystems.
Review Record
Code review verified the AST audit’s reverse floor assertions, untyped config.KVS composite literal inspection, CheckValidKeys value redaction (162ded343), and HasSensitiveKeys credential coverage (672c07a0f).
Summary
Shipped in Server 20260804 (162ded343 and 672c07a0f) with follow-up cleanup in Server 20260903 (f1ba68358), all notification KVS keys are verified against Default*KVS by AST tests and unrecognized-key errors redact values.