Skip to content

Commit d2a5440

Browse files
committed
Merge branch 'feature/security-hardening'
2 parents 70eb195 + 76d60d5 commit d2a5440

5 files changed

Lines changed: 223 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
# Changelog
22

3-
## 1.0.0b2 (unreleased)
3+
## 1.0.0b2
4+
5+
### Security
6+
7+
- **SQL identifier validation**: Added `validate_identifier()` in `columns.py`
8+
to reject unsafe SQL identifiers. All `idx_key` values in `IndexRegistry`
9+
and `date_attr` in `DateRecurringIndexTranslator` are now validated.
10+
11+
- **Access control declarations**: Added `declareProtected` for management
12+
methods (`refreshCatalog`, `reindexIndex`, `clearFindAndRebuild`) and
13+
`declarePrivate` for `unrestrictedSearchResults` on `PlonePGCatalogTool`.
14+
15+
- **API safety**: Renamed `execute_query()` to `_execute_query()` to mark as
16+
internal API. Capped path query list size to 100 (DoS prevention).
17+
Documented security contract for `IPGIndexTranslator` implementations.
418

519
### Fixed
620

tests/test_catalog_plone.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,43 @@ def test_implements_ipgcatalogtool(self):
1414
assert IPGCatalogTool.implementedBy(PlonePGCatalogTool)
1515

1616

17+
class TestSecurityDeclarations:
18+
"""Verify Zope security declarations on PlonePGCatalogTool."""
19+
20+
def test_unrestricted_search_is_private(self):
21+
# declarePrivate sets MethodName__roles__ = () (empty tuple = private)
22+
roles = getattr(PlonePGCatalogTool, "unrestrictedSearchResults__roles__", None)
23+
assert roles == (), f"Expected () for private, got {roles!r}"
24+
25+
def test_refresh_catalog_is_protected(self):
26+
# declareProtected sets MethodName__roles__ = PermissionRole(...)
27+
roles = getattr(PlonePGCatalogTool, "refreshCatalog__roles__", None)
28+
assert roles is not None and roles != ()
29+
30+
def test_reindex_index_is_protected(self):
31+
roles = getattr(PlonePGCatalogTool, "reindexIndex__roles__", None)
32+
assert roles is not None and roles != ()
33+
34+
def test_clear_find_and_rebuild_is_protected(self):
35+
roles = getattr(PlonePGCatalogTool, "clearFindAndRebuild__roles__", None)
36+
assert roles is not None and roles != ()
37+
38+
def test_ac_permissions_includes_manage_entries(self):
39+
# __ac_permissions__ maps permission → method names
40+
perms = PlonePGCatalogTool.__ac_permissions__
41+
manage_entries = None
42+
for perm_name, methods in perms:
43+
if perm_name == "Manage ZCatalog Entries":
44+
manage_entries = methods
45+
break
46+
assert manage_entries is not None, (
47+
"Manage ZCatalog Entries not in __ac_permissions__"
48+
)
49+
assert "refreshCatalog" in manage_entries
50+
assert "reindexIndex" in manage_entries
51+
assert "clearFindAndRebuild" in manage_entries
52+
53+
1754
class TestObjToZoid:
1855
def test_with_p_oid(self):
1956
obj = mock.Mock()

tests/test_dri.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ def __init__(self, start=None, end=None, recurrence=None):
3737
self.recurrence = recurrence
3838

3939

40+
# ---------------------------------------------------------------------------
41+
# Unit tests: constructor validation
42+
# ---------------------------------------------------------------------------
43+
44+
45+
class TestConstructorValidation:
46+
"""DateRecurringIndexTranslator rejects unsafe date_attr values."""
47+
48+
def test_accepts_valid_date_attr(self):
49+
t = DateRecurringIndexTranslator("start", "recurrence")
50+
assert t.date_attr == "start"
51+
52+
def test_rejects_single_quote_in_date_attr(self):
53+
with pytest.raises(ValueError, match="Invalid identifier"):
54+
DateRecurringIndexTranslator("start'", "recurrence")
55+
56+
def test_rejects_sql_injection_in_date_attr(self):
57+
with pytest.raises(ValueError, match="Invalid identifier"):
58+
DateRecurringIndexTranslator("'; DROP TABLE x; --", "recurrence")
59+
60+
def test_rejects_dash_in_date_attr(self):
61+
with pytest.raises(ValueError, match="Invalid identifier"):
62+
DateRecurringIndexTranslator("my-start", "recurrence")
63+
64+
4065
# ---------------------------------------------------------------------------
4166
# Unit tests: extract()
4267
# ---------------------------------------------------------------------------

tests/test_query.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,23 @@ def test_invalid_path_type_raises(self):
562562
with pytest.raises(ValueError, match="must be a string"):
563563
_validate_path(123)
564564

565+
def test_too_many_paths_raises(self):
566+
"""Path queries with more than _MAX_PATHS paths are rejected."""
567+
from plone.pgcatalog.query import _MAX_PATHS
568+
569+
import pytest
570+
571+
paths = [f"/plone/folder{i}" for i in range(_MAX_PATHS + 1)]
572+
with pytest.raises(ValueError, match="Too many paths"):
573+
build_query({"path": {"query": paths}})
574+
575+
def test_exactly_max_paths_accepted(self):
576+
from plone.pgcatalog.query import _MAX_PATHS
577+
578+
paths = [f"/plone/folder{i}" for i in range(_MAX_PATHS)]
579+
qr = build_query({"path": {"query": paths}})
580+
assert "idx" in qr["where"]
581+
565582

566583
class TestNavtreeEdgeCases:
567584
def test_navtree_breadcrumbs_empty(self):

tests/test_registry.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
597726
class TestGetRegistry:
598727
"""get_registry() returns module-level singleton."""
599728

0 commit comments

Comments
 (0)