diff --git a/lib/mongoid/errors.rb b/lib/mongoid/errors.rb index 669e2fb821..2362e44483 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