mongoid 8.1.12 → 8.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +26 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/nested/many.rb +38 -3
  5. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  6. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  7. data/lib/mongoid/config.rb +62 -0
  8. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  9. data/lib/mongoid/contextual/memory.rb +18 -7
  10. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  11. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  12. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  13. data/lib/mongoid/errors.rb +1 -0
  14. data/lib/mongoid/field_readable.rb +70 -0
  15. data/lib/mongoid/matchable.rb +6 -1
  16. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  17. data/lib/mongoid/matcher/regex.rb +8 -9
  18. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  19. data/lib/mongoid/matcher.rb +1 -0
  20. data/lib/mongoid/threaded.rb +3 -0
  21. data/lib/mongoid/version.rb +1 -1
  22. data/lib/mongoid/warnings.rb +1 -0
  23. data/spec/integration/app_spec.rb +8 -0
  24. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  25. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  26. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  27. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  28. data/spec/integration/query_operator_guard_spec.rb +89 -0
  29. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  30. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  31. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  32. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  33. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  34. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  35. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +235 -0
  36. data/spec/mongoid/criteria_spec.rb +2 -0
  37. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  38. metadata +11 -2
@@ -35,6 +35,8 @@ describe Mongoid::Criteria::Queryable::Selectable do
35
35
  it_behaves_like 'requires a non-nil argument'
36
36
 
37
37
  context "when provided a string" do
38
+ # String criteria compile to $where, which requires the opt-in.
39
+ config_override :allow_unsafe_query_operators, true
38
40
 
39
41
  let(:selection) do
40
42
  query.where("this.value = 10")
@@ -586,4 +588,237 @@ describe Mongoid::Criteria::Queryable::Selectable do
586
588
  end
587
589
  end
588
590
  end
591
+
592
+ describe 'top-level operator injection guard' do
593
+ context 'when allow_unsafe_query_operators is true' do
594
+ config_override :allow_unsafe_query_operators, true
595
+
596
+ context 'when passing $where' do
597
+ it 'does not raise' do
598
+ expect do
599
+ query.where('$where' => 'this.name == "admin"')
600
+ end.not_to raise_error
601
+ end
602
+ end
603
+
604
+ context 'when passing a string criterion' do
605
+ it 'does not raise' do
606
+ expect do
607
+ query.where('this.name == "admin"')
608
+ end.not_to raise_error
609
+ end
610
+ end
611
+
612
+ context 'when passing a string criterion to a negated query' do
613
+ it 'does not raise' do
614
+ expect do
615
+ query.not.where('this.name == "admin"')
616
+ end.not_to raise_error
617
+ end
618
+ end
619
+
620
+ context 'when passing $function' do
621
+ it 'does not raise' do
622
+ expect do
623
+ query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' })
624
+ end.not_to raise_error
625
+ end
626
+ end
627
+ end
628
+
629
+ context 'when allow_unsafe_query_operators is false' do
630
+ config_override :allow_unsafe_query_operators, false
631
+
632
+ context 'when passing $where' do
633
+ it 'raises InvalidQuery' do
634
+ expect do
635
+ query.where('$where' => 'this.name == "admin"')
636
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/)
637
+ end
638
+ end
639
+
640
+ context 'when passing $function' do
641
+ it 'raises InvalidQuery' do
642
+ expect do
643
+ query.where('$function' => { body: 'function() { return true; }', args: [], lang: 'js' })
644
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /\$function/)
645
+ end
646
+ end
647
+
648
+ context 'when the error mentions allow_unsafe_query_operators' do
649
+ it 'includes the config opt-in in the message' do
650
+ expect do
651
+ query.where('$where' => 'true')
652
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/)
653
+ end
654
+ end
655
+
656
+ context 'when passing a string criterion' do
657
+ it 'raises InvalidQuery' do
658
+ expect do
659
+ query.where('this.name == "admin"')
660
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/)
661
+ end
662
+
663
+ it 'includes the config opt-in in the message' do
664
+ expect do
665
+ query.where('this.name == "admin"')
666
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/)
667
+ end
668
+ end
669
+
670
+ context 'when passing a string criterion to a negated query' do
671
+ it 'raises InvalidQuery' do
672
+ expect do
673
+ query.not.where('this.name == "admin"')
674
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where/)
675
+ end
676
+ end
677
+
678
+ %w[$and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue].each do |op|
679
+ context "when passing #{op} (allowlisted)" do
680
+ it 'does not raise' do
681
+ value = case op
682
+ when '$and', '$or', '$nor', '$not' then [ { 'x' => 1 } ]
683
+ when '$text' then { '$search' => 'hi' }
684
+ when '$expr' then { '$gt' => [ '$a', 1 ] }
685
+ when '$jsonSchema' then { 'required' => [ 'x' ] }
686
+ else true
687
+ end
688
+ expect { query.where(op => value) }.not_to raise_error
689
+ end
690
+ end
691
+ end
692
+ end
693
+ end
694
+
695
+ describe 'nested operator injection guard' do
696
+ js = 'this.name == "admin"'
697
+
698
+ function_expr = {
699
+ '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' }
700
+ }.freeze
701
+
702
+ accumulator_expr = {
703
+ '$accumulator' => {
704
+ 'init' => 'function() { return 0; }',
705
+ 'accumulate' => 'function() { return 0; }',
706
+ 'accumulateArgs' => [],
707
+ 'merge' => 'function() { return 0; }',
708
+ 'lang' => 'js'
709
+ }
710
+ }.freeze
711
+
712
+ # Every path into the selector that does not route through #expr_query,
713
+ # plus the nested forms that a top-level key check cannot see.
714
+ unsafe_queries = {
715
+ 'and with a $where key' => ->(query) { query.and('$where' => js) },
716
+ 'or with a $where key' => ->(query) { query.or('$where' => js) },
717
+ 'nor with a $where key' => ->(query) { query.nor('$where' => js) },
718
+ 'not with a $where key' => ->(query) { query.not('$where' => js) },
719
+ 'any_of with a $where key' => ->(query) { query.any_of('$where' => js) },
720
+ 'none_of with a $where key' => ->(query) { query.none_of('$where' => js) },
721
+ 'elem_match with a nested $where' => ->(query) { query.elem_match(a: { '$where' => js }) },
722
+ 'where with $where inside $or' => ->(query) { query.where('$or' => [ { '$where' => js } ]) },
723
+ 'where with $function inside $expr' => ->(query) { query.where('$expr' => function_expr) },
724
+ 'where with $accumulator inside $expr' => ->(query) { query.where('$expr' => accumulator_expr) },
725
+ 'where with $where three levels deep' => lambda { |query|
726
+ query.where('$and' => [ { '$or' => [ { '$where' => js } ] } ])
727
+ },
728
+ 'where with a symbol $where key inside $or' => ->(query) { query.where('$or' => [ { :$where => js } ]) }
729
+ }.freeze
730
+
731
+ context 'when allow_unsafe_query_operators is false' do
732
+ config_override :allow_unsafe_query_operators, false
733
+
734
+ unsafe_queries.each do |description, builder|
735
+ context "when querying with #{description}" do
736
+ it 'raises InvalidQuery' do
737
+ expect do
738
+ builder.call(query)
739
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /\$where|\$function|\$accumulator/)
740
+ end
741
+ end
742
+ end
743
+
744
+ it 'includes the config opt-in in the message' do
745
+ expect do
746
+ query.or('$where' => js)
747
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /allow_unsafe_query_operators/)
748
+ end
749
+
750
+ context 'when the nested expression is safe' do
751
+ it 'permits $or with field expressions' do
752
+ expect do
753
+ query.where('$or' => [ { 'a' => 1 }, { 'b' => { '$gt' => 2 } } ])
754
+ end.not_to raise_error
755
+ end
756
+
757
+ it 'permits $expr with aggregation operators' do
758
+ expect do
759
+ query.where('$expr' => { '$gt' => [ '$a', '$b' ] })
760
+ end.not_to raise_error
761
+ end
762
+
763
+ it 'permits a field whose name resembles a javascript operator' do
764
+ expect do
765
+ query.where('where' => js)
766
+ end.not_to raise_error
767
+ end
768
+
769
+ it 'permits a value that resembles a javascript operator' do
770
+ expect do
771
+ query.where('name' => '$where')
772
+ end.not_to raise_error
773
+ end
774
+ end
775
+ end
776
+
777
+ context 'when allow_unsafe_query_operators is true' do
778
+ config_override :allow_unsafe_query_operators, true
779
+
780
+ unsafe_queries.each do |description, builder|
781
+ context "when querying with #{description}" do
782
+ it 'does not raise' do
783
+ expect { builder.call(query) }.not_to raise_error
784
+ end
785
+ end
786
+ end
787
+ end
788
+ end
789
+
790
+ describe 'operator guard on chained override methods' do
791
+ # On this branch overwrite_chained_operators defaults to false, which
792
+ # routes elem_match through the guarded and_with_operator funnel. Force
793
+ # the override path so the guard in Mergeable#__override__ is exercised.
794
+ config_override :overwrite_chained_operators, true
795
+
796
+ let(:js) { 'this.name == "admin"' }
797
+
798
+ context 'when allow_unsafe_query_operators is false' do
799
+ config_override :allow_unsafe_query_operators, false
800
+
801
+ it 'rejects a $where nested in elem_match' do
802
+ expect do
803
+ query.elem_match(a: { '$where' => js })
804
+ end.to raise_error(Mongoid::Errors::InvalidQuery, /executes server-side JavaScript/)
805
+ end
806
+
807
+ it 'permits an ordinary elem_match value' do
808
+ expect do
809
+ query.elem_match(a: { name: 'x' })
810
+ end.not_to raise_error
811
+ end
812
+ end
813
+
814
+ context 'when allow_unsafe_query_operators is true' do
815
+ config_override :allow_unsafe_query_operators, true
816
+
817
+ it 'permits a $where nested in elem_match' do
818
+ expect do
819
+ query.elem_match(a: { '$where' => js })
820
+ end.not_to raise_error
821
+ end
822
+ end
823
+ end
589
824
  end
@@ -2659,6 +2659,8 @@ describe Mongoid::Criteria do
2659
2659
  end
2660
2660
 
2661
2661
  context "when the criteria is not embedded" do
2662
+ # String criteria compile to $where, which requires the opt-in.
2663
+ config_override :allow_unsafe_query_operators, true
2662
2664
 
2663
2665
  let(:criteria) do
2664
2666
  Band.where("this.name == 'Depeche Mode'")