@@ -594,6 +594,135 @@ def test_resync_adds_new_metadata(self):
594594 assert "getObjSize" in registry .metadata
595595
596596
597+ class TestValidateIdentifier :
598+ """validate_identifier() rejects unsafe SQL identifiers."""
599+
600+ def test_accepts_simple_name (self ):
601+ from plone .pgcatalog .columns import validate_identifier
602+
603+ validate_identifier ("portal_type" ) # no exception
604+
605+ def test_accepts_underscore_prefix (self ):
606+ from plone .pgcatalog .columns import validate_identifier
607+
608+ validate_identifier ("_private" )
609+
610+ def test_accepts_uppercase (self ):
611+ from plone .pgcatalog .columns import validate_identifier
612+
613+ validate_identifier ("Subject" )
614+
615+ def test_accepts_alphanumeric (self ):
616+ from plone .pgcatalog .columns import validate_identifier
617+
618+ validate_identifier ("field_2" )
619+
620+ def test_rejects_single_quote (self ):
621+ from plone .pgcatalog .columns import validate_identifier
622+
623+ with pytest .raises (ValueError , match = "Invalid identifier" ):
624+ validate_identifier ("foo'bar" )
625+
626+ def test_rejects_semicolon (self ):
627+ from plone .pgcatalog .columns import validate_identifier
628+
629+ with pytest .raises (ValueError , match = "Invalid identifier" ):
630+ validate_identifier ("foo;DROP TABLE" )
631+
632+ def test_rejects_dash (self ):
633+ from plone .pgcatalog .columns import validate_identifier
634+
635+ with pytest .raises (ValueError , match = "Invalid identifier" ):
636+ validate_identifier ("my-index" )
637+
638+ def test_rejects_space (self ):
639+ from plone .pgcatalog .columns import validate_identifier
640+
641+ with pytest .raises (ValueError , match = "Invalid identifier" ):
642+ validate_identifier ("my index" )
643+
644+ def test_rejects_dot (self ):
645+ from plone .pgcatalog .columns import validate_identifier
646+
647+ with pytest .raises (ValueError , match = "Invalid identifier" ):
648+ validate_identifier ("schema.table" )
649+
650+ def test_rejects_leading_digit (self ):
651+ from plone .pgcatalog .columns import validate_identifier
652+
653+ with pytest .raises (ValueError , match = "Invalid identifier" ):
654+ validate_identifier ("1field" )
655+
656+ def test_rejects_sql_injection_payload (self ):
657+ from plone .pgcatalog .columns import validate_identifier
658+
659+ with pytest .raises (ValueError , match = "Invalid identifier" ):
660+ validate_identifier ("'; DROP TABLE object_state; --" )
661+
662+ def test_rejects_empty_string (self ):
663+ from plone .pgcatalog .columns import validate_identifier
664+
665+ with pytest .raises (ValueError , match = "Invalid identifier" ):
666+ validate_identifier ("" )
667+
668+
669+ class TestIndexRegistryRejectsUnsafeNames :
670+ """register() and sync_from_catalog() reject unsafe SQL identifiers."""
671+
672+ def test_register_rejects_unsafe_idx_key (self ):
673+ from plone .pgcatalog .columns import IndexRegistry
674+
675+ registry = IndexRegistry ()
676+ with pytest .raises (ValueError , match = "Invalid identifier" ):
677+ registry .register ("my_index" , IndexType .FIELD , "foo'bar" )
678+
679+ def test_register_allows_none_idx_key (self ):
680+ from plone .pgcatalog .columns import IndexRegistry
681+
682+ registry = IndexRegistry ()
683+ registry .register ("SearchableText" , IndexType .TEXT , None )
684+ assert "SearchableText" in registry
685+
686+ def test_sync_skips_unsafe_index_name (self ):
687+ from plone .pgcatalog .columns import IndexRegistry
688+
689+ idx = MockIndex ("FieldIndex" )
690+ idx .id = "bad-name"
691+ catalog = MockCatalog (indexes = {"bad-name" : idx })
692+
693+ registry = IndexRegistry ()
694+ registry .sync_from_catalog (catalog )
695+
696+ assert "bad-name" not in registry
697+
698+ def test_sync_skips_sql_injection_name (self ):
699+ from plone .pgcatalog .columns import IndexRegistry
700+
701+ idx = MockIndex ("FieldIndex" )
702+ idx .id = "'; DROP TABLE x; --"
703+ catalog = MockCatalog (indexes = {"'; DROP TABLE x; --" : idx })
704+
705+ registry = IndexRegistry ()
706+ registry .sync_from_catalog (catalog )
707+
708+ assert "'; DROP TABLE x; --" not in registry
709+
710+ def test_sync_accepts_safe_alongside_unsafe (self ):
711+ from plone .pgcatalog .columns import IndexRegistry
712+
713+ safe = MockIndex ("FieldIndex" )
714+ safe .id = "portal_type"
715+ unsafe = MockIndex ("FieldIndex" )
716+ unsafe .id = "bad-name"
717+ catalog = MockCatalog (indexes = {"portal_type" : safe , "bad-name" : unsafe })
718+
719+ registry = IndexRegistry ()
720+ registry .sync_from_catalog (catalog )
721+
722+ assert "portal_type" in registry
723+ assert "bad-name" not in registry
724+
725+
597726class TestGetRegistry :
598727 """get_registry() returns module-level singleton."""
599728
0 commit comments