Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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