From 24de3d4f73a652a016adb8dae07968fafbf440c2 Mon Sep 17 00:00:00 2001 From: Dmitry Rybakov Date: Fri, 8 May 2026 16:48:35 +0200 Subject: [PATCH] MONGOID-5937 redact credentials from client config error messages NoClientDatabase, NoClientHosts and MixedClientConfiguration interpolated the raw client configuration hash into their summary text via the %{config} placeholder in lib/config/locales/en.yml. When a mongoid.yml contained a uri with embedded userinfo, a :password option, or auto_encryption_options.kms_providers, those secrets were exposed verbatim in the exception message. MixedClientConfiguration is not rescued in the Railtie, so it propagates to Rails' error reporter and from there to Sentry, Bugsnag and similar trackers. Add Mongoid::Errors::ConfigRedactor.redact, which returns a copy of the config hash with :password and :auto_encryption_options replaced and the userinfo stripped from any string :uri. Wire the helper into all three error classes. --- lib/mongoid/errors.rb | 1 + lib/mongoid/errors/config_redactor.rb | 43 +++++++ .../errors/mixed_client_configuration.rb | 2 +- lib/mongoid/errors/no_client_database.rb | 2 +- lib/mongoid/errors/no_client_hosts.rb | 2 +- spec/mongoid/errors/config_redactor_spec.rb | 112 ++++++++++++++++++ .../errors/mixed_client_configuration_spec.rb | 31 +++++ .../mongoid/errors/no_client_database_spec.rb | 29 +++++ spec/mongoid/errors/no_client_hosts_spec.rb | 26 ++++ 9 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 lib/mongoid/errors/config_redactor.rb create mode 100644 spec/mongoid/errors/config_redactor_spec.rb diff --git a/lib/mongoid/errors.rb b/lib/mongoid/errors.rb index c3e7391a48..3db3a31b9b 100644 --- a/lib/mongoid/errors.rb +++ b/lib/mongoid/errors.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'mongoid/errors/mongoid_error' +require 'mongoid/errors/config_redactor' require 'mongoid/errors/ambiguous_relationship' require 'mongoid/errors/attribute_not_loaded' require 'mongoid/errors/callback' diff --git a/lib/mongoid/errors/config_redactor.rb b/lib/mongoid/errors/config_redactor.rb new file mode 100644 index 0000000000..25d0a14006 --- /dev/null +++ b/lib/mongoid/errors/config_redactor.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mongoid + module Errors + # Redacts credentials from a client configuration hash before it is + # interpolated into an exception message. + module ConfigRedactor + extend self + + REDACTED = '[REDACTED]' + + # Top-level keys whose values should be replaced wholesale. + SENSITIVE_KEYS = %w[password auto_encryption_options].freeze + + # Match the userinfo portion of a MongoDB connection string. + URI_USERINFO = %r{\A(mongodb(?:\+srv)?://)[^@/]+@}.freeze + + # Return a copy of the given config hash with sensitive values redacted. + # Recurses into nested hashes so that, e.g., `:options => + # { :auto_encryption_options => ... }` is also covered. Non-hash inputs + # are returned unchanged. + def redact(config) + return config unless config.is_a?(Hash) + + config.each_with_object({}) do |(key, value), result| + result[key] = redact_value(key, value) + end + end + + def redact_value(key, value) + if SENSITIVE_KEYS.include?(key.to_s) + REDACTED + elsif key.to_s == 'uri' && value.is_a?(String) + value.sub(URI_USERINFO, "\\1#{REDACTED}@") + elsif value.is_a?(Hash) + redact(value) + else + value + end + end + end + end +end diff --git a/lib/mongoid/errors/mixed_client_configuration.rb b/lib/mongoid/errors/mixed_client_configuration.rb index e3df8e6418..f839c45127 100644 --- a/lib/mongoid/errors/mixed_client_configuration.rb +++ b/lib/mongoid/errors/mixed_client_configuration.rb @@ -16,7 +16,7 @@ def initialize(name, config) super( compose_message( 'mixed_client_configuration', - { name: name, config: config } + { name: name, config: ConfigRedactor.redact(config) } ) ) end diff --git a/lib/mongoid/errors/no_client_database.rb b/lib/mongoid/errors/no_client_database.rb index 31adcecb32..e14d976835 100644 --- a/lib/mongoid/errors/no_client_database.rb +++ b/lib/mongoid/errors/no_client_database.rb @@ -15,7 +15,7 @@ def initialize(name, config) super( compose_message( 'no_client_database', - { name: name, config: config } + { name: name, config: ConfigRedactor.redact(config) } ) ) end diff --git a/lib/mongoid/errors/no_client_hosts.rb b/lib/mongoid/errors/no_client_hosts.rb index 955f14302b..1b119fa5b4 100644 --- a/lib/mongoid/errors/no_client_hosts.rb +++ b/lib/mongoid/errors/no_client_hosts.rb @@ -15,7 +15,7 @@ def initialize(name, config) super( compose_message( 'no_client_hosts', - { name: name, config: config } + { name: name, config: ConfigRedactor.redact(config) } ) ) end diff --git a/spec/mongoid/errors/config_redactor_spec.rb b/spec/mongoid/errors/config_redactor_spec.rb new file mode 100644 index 0000000000..6b5152c698 --- /dev/null +++ b/spec/mongoid/errors/config_redactor_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongoid::Errors::ConfigRedactor do + describe '.redact' do + subject(:redacted) { described_class.redact(config) } + + context 'when the config is not a hash' do + let(:config) { 'not a hash' } + + it 'returns the value unchanged' do + expect(redacted).to eq('not a hash') + end + end + + context 'when the config has a :password key' do + let(:config) { { hosts: [ 'localhost:27017' ], password: 's3cr3t' } } + + it 'replaces the password value' do + expect(redacted[:password]).to eq('[REDACTED]') + end + + it 'leaves other values intact' do + expect(redacted[:hosts]).to eq([ 'localhost:27017' ]) + end + end + + context 'when the config has a string "password" key' do + let(:config) { { 'password' => 's3cr3t' } } + + it 'replaces the password value' do + expect(redacted['password']).to eq('[REDACTED]') + end + end + + context 'when the config has a :uri with embedded credentials' do + let(:config) { { uri: 'mongodb://admin:s3cr3t@cluster.example.com/db' } } + + it 'strips the userinfo from the URI' do + expect(redacted[:uri]).to eq('mongodb://[REDACTED]@cluster.example.com/db') + end + end + + context 'when the config has a mongodb+srv URI' do + let(:config) { { uri: 'mongodb+srv://admin:s3cr3t@cluster.example.com/db' } } + + it 'strips the userinfo from the URI' do + expect(redacted[:uri]).to eq('mongodb+srv://[REDACTED]@cluster.example.com/db') + end + end + + context 'when the URI has no userinfo' do + let(:config) { { uri: 'mongodb://cluster.example.com/db' } } + + it 'leaves the URI unchanged' do + expect(redacted[:uri]).to eq('mongodb://cluster.example.com/db') + end + end + + context 'when the URI value is not a string' do + let(:config) { { uri: nil } } + + it 'leaves the value unchanged' do + expect(redacted[:uri]).to be_nil + end + end + + context 'when the config has nested auto_encryption_options' do + let(:kms) do + { + aws: { access_key_id: 'AKIA...', secret_access_key: 'secret-value' }, + local: { key: 'A' * 96 } + } + end + let(:config) do + { + database: 'mongoid_test', + options: { + auto_encryption_options: { + key_vault_namespace: 'admin.datakeys', + kms_providers: kms + } + } + } + end + + it 'redacts the entire auto_encryption_options value' do + expect(redacted[:options][:auto_encryption_options]).to eq('[REDACTED]') + end + + it 'preserves sibling values in the parent hash' do + expect(redacted[:database]).to eq('mongoid_test') + end + + it 'does not contain any kms secret in its serialized form' do + expect(redacted.to_s).not_to include('secret-value') + expect(redacted.to_s).not_to include('AKIA') + expect(redacted.to_s).not_to include('A' * 96) + end + end + + context 'when redaction would mutate the input' do + let(:config) { { password: 's3cr3t', options: { foo: 'bar' } } } + + it 'does not modify the original hash' do + described_class.redact(config) + expect(config[:password]).to eq('s3cr3t') + end + end + end +end diff --git a/spec/mongoid/errors/mixed_client_configuration_spec.rb b/spec/mongoid/errors/mixed_client_configuration_spec.rb index 5828f72666..2a357cf193 100644 --- a/spec/mongoid/errors/mixed_client_configuration_spec.rb +++ b/spec/mongoid/errors/mixed_client_configuration_spec.rb @@ -25,5 +25,36 @@ 'Provide either only a uri as configuration' ) end + + context 'when the config contains sensitive values' do + let(:error) do + described_class.new( + :testing, + { + uri: 'mongodb://admin:s3cr3t@cluster.example.com/db', + password: 'standalone-secret', + options: { + auto_encryption_options: { + kms_providers: { local: { key: 'A' * 96 } } + } + } + } + ) + end + + it 'redacts the URI userinfo' do + expect(error.message).not_to include('admin:s3cr3t') + expect(error.message).to include('mongodb://[REDACTED]@cluster.example.com/db') + end + + it 'redacts the standalone password value' do + expect(error.message).not_to include('standalone-secret') + end + + it 'redacts auto_encryption_options entirely' do + expect(error.message).not_to include('kms_providers') + expect(error.message).not_to include('A' * 96) + end + end end end diff --git a/spec/mongoid/errors/no_client_database_spec.rb b/spec/mongoid/errors/no_client_database_spec.rb index 285f0cfb0b..67ace2af45 100644 --- a/spec/mongoid/errors/no_client_database_spec.rb +++ b/spec/mongoid/errors/no_client_database_spec.rb @@ -25,5 +25,34 @@ 'If configuring via a mongoid.yml, ensure that within your :analytics' ) end + + context 'when the config contains sensitive values' do + let(:error) do + described_class.new( + :analytics, + { + hosts: [ '127.0.0.1:27017' ], + password: 's3cr3t', + options: { + auto_encryption_options: { + kms_providers: { + aws: { access_key_id: 'AKIAEXAMPLE', secret_access_key: 'aws-secret' } + } + } + } + } + ) + end + + it 'redacts the password value' do + expect(error.message).not_to include('s3cr3t') + end + + it 'redacts auto_encryption_options entirely' do + expect(error.message).not_to include('kms_providers') + expect(error.message).not_to include('aws-secret') + expect(error.message).not_to include('AKIAEXAMPLE') + end + end end end diff --git a/spec/mongoid/errors/no_client_hosts_spec.rb b/spec/mongoid/errors/no_client_hosts_spec.rb index fb62f081e1..2e89b9739c 100644 --- a/spec/mongoid/errors/no_client_hosts_spec.rb +++ b/spec/mongoid/errors/no_client_hosts_spec.rb @@ -25,5 +25,31 @@ 'If configuring via a mongoid.yml, ensure that within your :analytics' ) end + + context 'when the config contains sensitive values' do + let(:error) do + described_class.new( + :analytics, + { + database: 'mongoid_test', + password: 's3cr3t', + options: { + auto_encryption_options: { + kms_providers: { local: { key: 'A' * 96 } } + } + } + } + ) + end + + it 'redacts the password value' do + expect(error.message).not_to include('s3cr3t') + end + + it 'redacts auto_encryption_options entirely' do + expect(error.message).not_to include('kms_providers') + expect(error.message).not_to include('A' * 96) + end + end end end