diff --git a/Gemfile b/Gemfile index 69d793e7e7..a151034f03 100644 --- a/Gemfile +++ b/Gemfile @@ -25,3 +25,5 @@ gem 'i18n', *i18n_versions # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] + +gem 'ostruct' if RUBY_VERSION >= '4.0' diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index 98ae824d1b..4df6129d90 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -294,6 +294,15 @@ def validate_isolation_level!(level) # See https://jira.mongodb.org/browse/MONGOID-5892 for more details. option :serializable_hash_with_legacy_only, default: true + # When true (default), all top-level query operators are passed through + # to MongoDB without restriction when using +where+/+find_by+. Set to + # false to enable a strict allowlist that rejects operators like +$where+ + # and +$function+, which can execute arbitrary JavaScript when user-supplied + # input reaches the query builder. + # + # See https://jira.mongodb.org/browse/MONGOID-5939 for details. + option :allow_unsafe_query_operators, default: true + # Returns the Config singleton, for use in the configure DSL. # # @return [ self ] The Config singleton. diff --git a/lib/mongoid/criteria/queryable/selectable.rb b/lib/mongoid/criteria/queryable/selectable.rb index 8317c9befd..41035a4c8d 100644 --- a/lib/mongoid/criteria/queryable/selectable.rb +++ b/lib/mongoid/criteria/queryable/selectable.rb @@ -792,6 +792,13 @@ def where(*criteria) # # @return [ Selectable ] The cloned selectable. # @api private + # Operators permitted in a query expression without opt-in. + # Excludes $where (JS execution) and other operators not needed for + # ordinary application queries. + ALLOWED_QUERY_OPERATORS = %w[ + $and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue + ].freeze + def expr_query(criterion) raise ArgumentError, 'Criterion cannot be nil here' if criterion.nil? unless criterion.is_a?(Hash) @@ -803,7 +810,11 @@ def expr_query(criterion) normalized.each do |field, value| field_s = field.to_s if field_s.start_with?('$') - # Query expression-level operator, like $and or $where + unless Mongoid.allow_unsafe_query_operators? || ALLOWED_QUERY_OPERATORS.include?(field_s) + raise Errors::InvalidQuery, + "Operator '#{field_s}' is not allowed in a query expression. " \ + "Set Mongoid.allow_unsafe_query_operators = true to permit all operators." + end query.add_operator_expression(field_s, value) else query.add_field_expression(field, value) diff --git a/spec/mongoid/criteria/queryable/selectable_spec.rb b/spec/mongoid/criteria/queryable/selectable_spec.rb index ae142711c4..4f240493ce 100644 --- a/spec/mongoid/criteria/queryable/selectable_spec.rb +++ b/spec/mongoid/criteria/queryable/selectable_spec.rb @@ -2163,4 +2163,5 @@ def localized? end end end + end diff --git a/spec/mongoid/criteria/queryable/selectable_where_spec.rb b/spec/mongoid/criteria/queryable/selectable_where_spec.rb index f066f037bd..39c6752652 100644 --- a/spec/mongoid/criteria/queryable/selectable_where_spec.rb +++ b/spec/mongoid/criteria/queryable/selectable_where_spec.rb @@ -550,4 +550,67 @@ def self.evolve(object) end end end + + describe "top-level operator injection guard" do + context "when allow_unsafe_query_operators is true (default)" do + context "when passing $where" do + it "does not raise" do + expect { + query.where('$where' => 'this.name == "admin"') + }.not_to raise_error + end + end + + context "when passing $function" do + it "does not raise" do + expect { + query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' }) + }.not_to raise_error + end + end + end + + context "when allow_unsafe_query_operators is false" do + config_override :allow_unsafe_query_operators, false + + context "when passing $where" do + it "raises InvalidQuery" do + expect { + query.where('$where' => 'this.name == "admin"') + }.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/) + end + end + + context "when passing $function" do + it "raises InvalidQuery" do + expect { + query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' }) + }.to raise_error(Mongoid::Errors::InvalidQuery, /\$function/) + end + end + + context "when the error mentions allow_unsafe_query_operators" do + it "includes the config opt-in in the message" do + expect { + query.where('$where' => 'true') + }.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/) + end + end + + %w[$and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue].each do |op| + context "when passing #{op} (allowlisted)" do + it "does not raise" do + value = case op + when '$and', '$or', '$nor', '$not' then [{ 'x' => 1 }] + when '$text' then { '$search' => 'hi' } + when '$expr' then { '$gt' => ['$a', 1] } + when '$jsonSchema' then { 'required' => ['x'] } + else true + end + expect { query.where(op => value) }.not_to raise_error + end + end + end + end + end end