mongoid 8.1.11 → 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 (50) 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/embedded/batchable.rb +11 -10
  5. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +61 -0
  6. data/lib/mongoid/association/nested/many.rb +38 -3
  7. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  8. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  9. data/lib/mongoid/clients/factory.rb +4 -0
  10. data/lib/mongoid/config.rb +62 -0
  11. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  12. data/lib/mongoid/contextual/memory.rb +18 -7
  13. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  14. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  15. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  16. data/lib/mongoid/errors.rb +1 -0
  17. data/lib/mongoid/extensions/hash.rb +27 -1
  18. data/lib/mongoid/field_readable.rb +70 -0
  19. data/lib/mongoid/matchable.rb +6 -1
  20. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  21. data/lib/mongoid/matcher/regex.rb +8 -9
  22. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  23. data/lib/mongoid/matcher.rb +1 -0
  24. data/lib/mongoid/threaded.rb +3 -0
  25. data/lib/mongoid/touchable.rb +1 -1
  26. data/lib/mongoid/version.rb +1 -1
  27. data/lib/mongoid/warnings.rb +1 -0
  28. data/spec/integration/app_spec.rb +8 -0
  29. data/spec/integration/associations/embeds_many_spec.rb +41 -0
  30. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  31. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  32. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  33. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  34. data/spec/integration/query_operator_guard_spec.rb +89 -0
  35. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  36. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  37. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  38. data/spec/mongoid/clients/factory_spec.rb +30 -0
  39. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  40. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  41. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  42. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +235 -0
  43. data/spec/mongoid/criteria_spec.rb +2 -0
  44. data/spec/mongoid/extensions/hash_spec.rb +236 -0
  45. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  46. data/spec/mongoid/touchable_spec.rb +12 -0
  47. data/spec/shared/CANDIDATE.md +28 -0
  48. data/spec/shared/lib/mrss/spec_organizer.rb +32 -3
  49. data/spec/shared/shlib/server.sh +1 -1
  50. metadata +13 -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'")
@@ -497,4 +497,240 @@ describe Mongoid::Extensions::Hash do
497
497
 
498
498
  it_behaves_like 'unsatisfiable criteria method'
499
499
  end
500
+
501
+ describe '#to_criteria' do
502
+ subject(:criteria) { hash.to_criteria }
503
+
504
+ context 'when klass is specified' do
505
+ let(:hash) do
506
+ { klass: Band, where: { name: 'Songs Ohia' } }
507
+ end
508
+
509
+ it 'returns a criteria' do
510
+ expect(criteria).to be_a(Mongoid::Criteria)
511
+ end
512
+
513
+ it 'sets the klass' do
514
+ expect(criteria.klass).to eq(Band)
515
+ end
516
+
517
+ it 'sets the selector' do
518
+ expect(criteria.selector).to eq({ 'name' => 'Songs Ohia' })
519
+ end
520
+ end
521
+
522
+ context 'when klass is missing' do
523
+ let(:hash) do
524
+ { where: { name: 'Songs Ohia' } }
525
+ end
526
+
527
+ it 'returns a criteria' do
528
+ expect(criteria).to be_a(Mongoid::Criteria)
529
+ end
530
+
531
+ it 'has klass nil' do
532
+ expect(criteria.klass).to be_nil
533
+ end
534
+
535
+ it 'sets the selector' do
536
+ expect(criteria.selector).to eq({ 'name' => 'Songs Ohia' })
537
+ end
538
+ end
539
+
540
+ context 'with allowed methods' do
541
+ context 'when using multiple query methods' do
542
+ let(:hash) do
543
+ {
544
+ klass: Band,
545
+ where: { active: true },
546
+ limit: 10,
547
+ skip: 5,
548
+ order_by: { name: 1 }
549
+ }
550
+ end
551
+
552
+ it 'applies all methods successfully' do
553
+ expect(criteria.selector).to eq({ 'active' => true })
554
+ expect(criteria.options[:limit]).to eq(10)
555
+ expect(criteria.options[:skip]).to eq(5)
556
+ expect(criteria.options[:sort]).to eq({ 'name' => 1 })
557
+ end
558
+ end
559
+
560
+ context 'when using query selector methods' do
561
+ let(:hash) do
562
+ {
563
+ klass: Band,
564
+ gt: { members: 2 },
565
+ in: { genre: ['rock', 'metal'] }
566
+ }
567
+ end
568
+
569
+ it 'applies selector methods' do
570
+ expect(criteria.selector['members']).to eq({ '$gt' => 2 })
571
+ expect(criteria.selector['genre']).to eq({ '$in' => ['rock', 'metal'] })
572
+ end
573
+ end
574
+
575
+ context 'when using aggregation methods' do
576
+ let(:hash) do
577
+ {
578
+ klass: Band,
579
+ project: { name: 1, members: 1 }
580
+ }
581
+ end
582
+
583
+ it 'applies aggregation methods' do
584
+ expect { criteria }.not_to raise_error
585
+ end
586
+ end
587
+ end
588
+
589
+ context 'with disallowed methods' do
590
+ context 'when attempting to call create' do
591
+ let(:hash) do
592
+ { klass: Band, create: { name: 'Malicious' } }
593
+ end
594
+
595
+ it 'raises ArgumentError' do
596
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create' is not allowed in to_criteria")
597
+ end
598
+ end
599
+
600
+ context 'when attempting to call create!' do
601
+ let(:hash) do
602
+ { klass: Band, 'create!': { name: 'Malicious' } }
603
+ end
604
+
605
+ it 'raises ArgumentError' do
606
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create!' is not allowed in to_criteria")
607
+ end
608
+ end
609
+
610
+ context 'when attempting to call build' do
611
+ let(:hash) do
612
+ { klass: Band, build: { name: 'Malicious' } }
613
+ end
614
+
615
+ it 'raises ArgumentError' do
616
+ expect { criteria }.to raise_error(ArgumentError, "Method 'build' is not allowed in to_criteria")
617
+ end
618
+ end
619
+
620
+ context 'when attempting to call find' do
621
+ let(:hash) do
622
+ { klass: Band, find: 'some_id' }
623
+ end
624
+
625
+ it 'raises ArgumentError' do
626
+ expect { criteria }.to raise_error(ArgumentError, "Method 'find' is not allowed in to_criteria")
627
+ end
628
+ end
629
+
630
+ context 'when attempting to call execute_or_raise' do
631
+ let(:hash) do
632
+ { klass: Band, execute_or_raise: ['id1', 'id2'] }
633
+ end
634
+
635
+ it 'raises ArgumentError' do
636
+ expect { criteria }.to raise_error(ArgumentError, "Method 'execute_or_raise' is not allowed in to_criteria")
637
+ end
638
+ end
639
+
640
+ context 'when attempting to call new' do
641
+ let(:hash) do
642
+ { klass: Band, new: { name: 'Test' } }
643
+ end
644
+
645
+ it 'raises ArgumentError' do
646
+ expect { criteria }.to raise_error(ArgumentError, "Method 'new' is not allowed in to_criteria")
647
+ end
648
+ end
649
+
650
+ context 'when allowed method is combined with disallowed method' do
651
+ let(:hash) do
652
+ {
653
+ klass: Band,
654
+ where: { active: true },
655
+ create: { name: 'Malicious' }
656
+ }
657
+ end
658
+
659
+ it 'raises ArgumentError before executing any methods' do
660
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create' is not allowed in to_criteria")
661
+ end
662
+ end
663
+ end
664
+
665
+ context 'security validation' do
666
+ # This test ensures that ALL public methods not in the allowlist are blocked
667
+ it 'blocks all dangerous public methods' do
668
+ dangerous_methods = %i[
669
+ build create create! new
670
+ find find_or_create_by find_or_create_by! find_or_initialize_by
671
+ first_or_create first_or_create! first_or_initialize
672
+ execute_or_raise multiple_from_db for_ids
673
+ documents= inclusions= scoping_options=
674
+ initialize freeze as_json
675
+ ]
676
+
677
+ dangerous_methods.each do |method|
678
+ hash = { klass: Band, method => 'arg' }
679
+ expect { hash.to_criteria }.to raise_error(
680
+ ArgumentError,
681
+ "Method '#{method}' is not allowed in to_criteria"
682
+ ), "Expected method '#{method}' to be blocked but it was allowed"
683
+ end
684
+ end
685
+
686
+ it 'blocks dangerous inherited methods from Object' do
687
+ # Critical security test: block send, instance_eval, etc.
688
+ inherited_dangerous = %i[
689
+ send __send__ instance_eval instance_exec
690
+ instance_variable_set method
691
+ ]
692
+
693
+ inherited_dangerous.each do |method|
694
+ hash = { klass: Band, method => 'arg' }
695
+ expect { hash.to_criteria }.to raise_error(
696
+ ArgumentError,
697
+ "Method '#{method}' is not allowed in to_criteria"
698
+ ), "Expected inherited method '#{method}' to be blocked"
699
+ end
700
+ end
701
+
702
+ it 'blocks Enumerable execution methods' do
703
+ # to_criteria should build queries, not execute them
704
+ enumerable_methods = %i[each map select count sum]
705
+
706
+ enumerable_methods.each do |method|
707
+ hash = { klass: Band, method => 'arg' }
708
+ expect { hash.to_criteria }.to raise_error(
709
+ ArgumentError,
710
+ "Method '#{method}' is not allowed in to_criteria"
711
+ ), "Expected Enumerable method '#{method}' to be blocked"
712
+ end
713
+ end
714
+
715
+ it 'allows all whitelisted methods' do
716
+ # Sample of allowed methods from each category
717
+ allowed_sample = {
718
+ where: { name: 'Test' }, # Query selector
719
+ limit: 10, # Query option
720
+ skip: 5, # Query option
721
+ gt: { age: 18 }, # Query selector
722
+ in: { status: ['active'] }, # Query selector
723
+ ascending: :name, # Sorting
724
+ includes: :notes, # Eager loading
725
+ merge: { klass: Band }, # Merge
726
+ }
727
+
728
+ allowed_sample.each do |method, args|
729
+ hash = { klass: Band, method => args }
730
+ expect { hash.to_criteria }.not_to raise_error,
731
+ "Expected method '#{method}' to be allowed but it was blocked"
732
+ end
733
+ end
734
+ end
735
+ end
500
736
  end