Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/mongoid/errors.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
43 changes: 43 additions & 0 deletions lib/mongoid/errors/config_redactor.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/mongoid/errors/mixed_client_configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/errors/no_client_database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/errors/no_client_hosts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions spec/mongoid/errors/config_redactor_spec.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions spec/mongoid/errors/mixed_client_configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions spec/mongoid/errors/no_client_database_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions spec/mongoid/errors/no_client_hosts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading