mongoid 7.6.0 → 7.6.2

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 (49) 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 +102 -3
  7. data/lib/mongoid/clients/factory.rb +4 -0
  8. data/lib/mongoid/config.rb +62 -0
  9. data/lib/mongoid/contextual/aggregable/memory.rb +8 -2
  10. data/lib/mongoid/contextual/memory.rb +19 -8
  11. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  12. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  13. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  14. data/lib/mongoid/errors.rb +1 -0
  15. data/lib/mongoid/extensions/hash.rb +27 -1
  16. data/lib/mongoid/field_readable.rb +70 -0
  17. data/lib/mongoid/matchable.rb +6 -1
  18. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  19. data/lib/mongoid/matcher/regex.rb +8 -9
  20. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  21. data/lib/mongoid/matcher.rb +1 -0
  22. data/lib/mongoid/threaded.rb +3 -0
  23. data/lib/mongoid/version.rb +1 -1
  24. data/lib/mongoid/warnings.rb +1 -0
  25. data/spec/integration/app_spec.rb +8 -0
  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 +202 -8
  32. data/spec/mongoid/clients/factory_spec.rb +31 -0
  33. data/spec/mongoid/contextual/aggregable/memory_spec.rb +98 -0
  34. data/spec/mongoid/contextual/memory_spec.rb +196 -0
  35. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  36. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  37. data/spec/mongoid/criteria_spec.rb +2 -0
  38. data/spec/mongoid/extensions/hash_spec.rb +236 -0
  39. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  40. data/spec/shared/lib/mrss/cluster_config.rb +5 -40
  41. data/spec/shared/lib/mrss/constraints.rb +7 -46
  42. data/spec/shared/lib/mrss/docker_runner.rb +10 -21
  43. data/spec/shared/lib/mrss/eg_config_utils.rb +0 -31
  44. data/spec/shared/lib/mrss/release/candidate.rb +18 -12
  45. data/spec/shared/lib/mrss/server_version_registry.rb +1 -5
  46. data/spec/shared/share/Dockerfile.erb +6 -8
  47. data/spec/shared/shlib/server.sh +0 -50
  48. data/spec/shared/shlib/set_env.sh +4 -10
  49. 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,202 @@ 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 is not available on this branch; it landed in 8.1 via MONGOID-5453.
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
589
789
  end
@@ -3695,6 +3695,8 @@ describe Mongoid::Criteria do
3695
3695
  end
3696
3696
 
3697
3697
  context "when the criteria is not embedded" do
3698
+ # String criteria compile to $where, which requires the opt-in.
3699
+ config_override :allow_unsafe_query_operators, true
3698
3700
 
3699
3701
  let(:criteria) do
3700
3702
  Band.where("this.name == 'Depeche Mode'")
@@ -467,4 +467,240 @@ describe Mongoid::Extensions::Hash do
467
467
 
468
468
  it_behaves_like 'unsatisfiable criteria method'
469
469
  end
470
+
471
+ describe '#to_criteria' do
472
+ subject(:criteria) { hash.to_criteria }
473
+
474
+ context 'when klass is specified' do
475
+ let(:hash) do
476
+ { klass: Band, where: { name: 'Songs Ohia' } }
477
+ end
478
+
479
+ it 'returns a criteria' do
480
+ expect(criteria).to be_a(Mongoid::Criteria)
481
+ end
482
+
483
+ it 'sets the klass' do
484
+ expect(criteria.klass).to eq(Band)
485
+ end
486
+
487
+ it 'sets the selector' do
488
+ expect(criteria.selector).to eq({ 'name' => 'Songs Ohia' })
489
+ end
490
+ end
491
+
492
+ context 'when klass is missing' do
493
+ let(:hash) do
494
+ { where: { name: 'Songs Ohia' } }
495
+ end
496
+
497
+ it 'returns a criteria' do
498
+ expect(criteria).to be_a(Mongoid::Criteria)
499
+ end
500
+
501
+ it 'has klass nil' do
502
+ expect(criteria.klass).to be_nil
503
+ end
504
+
505
+ it 'sets the selector' do
506
+ expect(criteria.selector).to eq({ 'name' => 'Songs Ohia' })
507
+ end
508
+ end
509
+
510
+ context 'with allowed methods' do
511
+ context 'when using multiple query methods' do
512
+ let(:hash) do
513
+ {
514
+ klass: Band,
515
+ where: { active: true },
516
+ limit: 10,
517
+ skip: 5,
518
+ order_by: { name: 1 }
519
+ }
520
+ end
521
+
522
+ it 'applies all methods successfully' do
523
+ expect(criteria.selector).to eq({ 'active' => true })
524
+ expect(criteria.options[:limit]).to eq(10)
525
+ expect(criteria.options[:skip]).to eq(5)
526
+ expect(criteria.options[:sort]).to eq({ 'name' => 1 })
527
+ end
528
+ end
529
+
530
+ context 'when using query selector methods' do
531
+ let(:hash) do
532
+ {
533
+ klass: Band,
534
+ gt: { members: 2 },
535
+ in: { genre: ['rock', 'metal'] }
536
+ }
537
+ end
538
+
539
+ it 'applies selector methods' do
540
+ expect(criteria.selector['members']).to eq({ '$gt' => 2 })
541
+ expect(criteria.selector['genre']).to eq({ '$in' => ['rock', 'metal'] })
542
+ end
543
+ end
544
+
545
+ context 'when using aggregation methods' do
546
+ let(:hash) do
547
+ {
548
+ klass: Band,
549
+ project: { name: 1, members: 1 }
550
+ }
551
+ end
552
+
553
+ it 'applies aggregation methods' do
554
+ expect { criteria }.not_to raise_error
555
+ end
556
+ end
557
+ end
558
+
559
+ context 'with disallowed methods' do
560
+ context 'when attempting to call create' do
561
+ let(:hash) do
562
+ { klass: Band, create: { name: 'Malicious' } }
563
+ end
564
+
565
+ it 'raises ArgumentError' do
566
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create' is not allowed in to_criteria")
567
+ end
568
+ end
569
+
570
+ context 'when attempting to call create!' do
571
+ let(:hash) do
572
+ { klass: Band, 'create!': { name: 'Malicious' } }
573
+ end
574
+
575
+ it 'raises ArgumentError' do
576
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create!' is not allowed in to_criteria")
577
+ end
578
+ end
579
+
580
+ context 'when attempting to call build' do
581
+ let(:hash) do
582
+ { klass: Band, build: { name: 'Malicious' } }
583
+ end
584
+
585
+ it 'raises ArgumentError' do
586
+ expect { criteria }.to raise_error(ArgumentError, "Method 'build' is not allowed in to_criteria")
587
+ end
588
+ end
589
+
590
+ context 'when attempting to call find' do
591
+ let(:hash) do
592
+ { klass: Band, find: 'some_id' }
593
+ end
594
+
595
+ it 'raises ArgumentError' do
596
+ expect { criteria }.to raise_error(ArgumentError, "Method 'find' is not allowed in to_criteria")
597
+ end
598
+ end
599
+
600
+ context 'when attempting to call execute_or_raise' do
601
+ let(:hash) do
602
+ { klass: Band, execute_or_raise: ['id1', 'id2'] }
603
+ end
604
+
605
+ it 'raises ArgumentError' do
606
+ expect { criteria }.to raise_error(ArgumentError, "Method 'execute_or_raise' is not allowed in to_criteria")
607
+ end
608
+ end
609
+
610
+ context 'when attempting to call new' do
611
+ let(:hash) do
612
+ { klass: Band, new: { name: 'Test' } }
613
+ end
614
+
615
+ it 'raises ArgumentError' do
616
+ expect { criteria }.to raise_error(ArgumentError, "Method 'new' is not allowed in to_criteria")
617
+ end
618
+ end
619
+
620
+ context 'when allowed method is combined with disallowed method' do
621
+ let(:hash) do
622
+ {
623
+ klass: Band,
624
+ where: { active: true },
625
+ create: { name: 'Malicious' }
626
+ }
627
+ end
628
+
629
+ it 'raises ArgumentError before executing any methods' do
630
+ expect { criteria }.to raise_error(ArgumentError, "Method 'create' is not allowed in to_criteria")
631
+ end
632
+ end
633
+ end
634
+
635
+ context 'security validation' do
636
+ # This test ensures that ALL public methods not in the allowlist are blocked
637
+ it 'blocks all dangerous public methods' do
638
+ dangerous_methods = %i[
639
+ build create create! new
640
+ find find_or_create_by find_or_create_by! find_or_initialize_by
641
+ first_or_create first_or_create! first_or_initialize
642
+ execute_or_raise multiple_from_db for_ids
643
+ documents= inclusions= scoping_options=
644
+ initialize freeze as_json
645
+ ]
646
+
647
+ dangerous_methods.each do |method|
648
+ hash = { klass: Band, method => 'arg' }
649
+ expect { hash.to_criteria }.to raise_error(
650
+ ArgumentError,
651
+ "Method '#{method}' is not allowed in to_criteria"
652
+ ), "Expected method '#{method}' to be blocked but it was allowed"
653
+ end
654
+ end
655
+
656
+ it 'blocks dangerous inherited methods from Object' do
657
+ # Critical security test: block send, instance_eval, etc.
658
+ inherited_dangerous = %i[
659
+ send __send__ instance_eval instance_exec
660
+ instance_variable_set method
661
+ ]
662
+
663
+ inherited_dangerous.each do |method|
664
+ hash = { klass: Band, method => 'arg' }
665
+ expect { hash.to_criteria }.to raise_error(
666
+ ArgumentError,
667
+ "Method '#{method}' is not allowed in to_criteria"
668
+ ), "Expected inherited method '#{method}' to be blocked"
669
+ end
670
+ end
671
+
672
+ it 'blocks Enumerable execution methods' do
673
+ # to_criteria should build queries, not execute them
674
+ enumerable_methods = %i[each map select count sum]
675
+
676
+ enumerable_methods.each do |method|
677
+ hash = { klass: Band, method => 'arg' }
678
+ expect { hash.to_criteria }.to raise_error(
679
+ ArgumentError,
680
+ "Method '#{method}' is not allowed in to_criteria"
681
+ ), "Expected Enumerable method '#{method}' to be blocked"
682
+ end
683
+ end
684
+
685
+ it 'allows all whitelisted methods' do
686
+ # Sample of allowed methods from each category
687
+ allowed_sample = {
688
+ where: { name: 'Test' }, # Query selector
689
+ limit: 10, # Query option
690
+ skip: 5, # Query option
691
+ gt: { age: 18 }, # Query selector
692
+ in: { status: ['active'] }, # Query selector
693
+ ascending: :name, # Sorting
694
+ includes: :notes, # Eager loading
695
+ merge: { klass: Band }, # Merge
696
+ }
697
+
698
+ allowed_sample.each do |method, args|
699
+ hash = { klass: Band, method => args }
700
+ expect { hash.to_criteria }.not_to raise_error,
701
+ "Expected method '#{method}' to be allowed but it was blocked"
702
+ end
703
+ end
704
+ end
705
+ end
470
706
  end