Skip to content

Commit 70eb195

Browse files
authored
Merge pull request #2 from bluedynamics/feature/security-hardening
Security hardening: SQL identifier validation, access control, API safety
2 parents c8fae5f + ea1cf13 commit 70eb195

11 files changed

Lines changed: 81 additions & 9 deletions

File tree

src/plone/pgcatalog/catalog.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ class PlonePGCatalogTool(CatalogTool):
222222
meta_type = "PG Catalog Tool"
223223
security = ClassSecurityInfo()
224224

225+
security.declarePrivate("unrestrictedSearchResults")
226+
security.declareProtected("Manage ZCatalog Entries", "refreshCatalog")
227+
security.declareProtected("Manage ZCatalog Entries", "reindexIndex")
228+
security.declareProtected("Manage ZCatalog Entries", "clearFindAndRebuild")
229+
225230
@contextmanager
226231
def _pg_connection(self):
227232
"""Get a connection, preferring request-scoped reuse.

src/plone/pgcatalog/columns.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,27 @@
1515
from enum import Enum
1616

1717
import logging
18+
import re
1819

1920

2021
log = logging.getLogger(__name__)
2122

23+
# Safe SQL identifier pattern: letters, digits, underscores only.
24+
# Prevents SQL injection when idx_key values are interpolated into queries.
25+
_SAFE_IDENTIFIER_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
26+
27+
28+
def validate_identifier(name):
29+
"""Validate that a name is safe for use as a SQL identifier.
30+
31+
Raises ValueError if the name contains characters that could
32+
enable SQL injection when interpolated into query strings.
33+
"""
34+
if not _SAFE_IDENTIFIER_RE.match(name):
35+
raise ValueError(
36+
f"Invalid identifier {name!r}: must match [a-zA-Z_][a-zA-Z0-9_]*"
37+
)
38+
2239

2340
class IndexType(Enum):
2441
"""ZCatalog index types we handle natively."""
@@ -104,6 +121,16 @@ def sync_from_catalog(self, catalog):
104121
)
105122
continue
106123

124+
# Validate index name as safe SQL identifier
125+
if name not in SPECIAL_INDEXES:
126+
try:
127+
validate_identifier(name)
128+
except ValueError:
129+
log.warning(
130+
"Skipping index %r: name is not a safe SQL identifier", name
131+
)
132+
continue
133+
107134
# Read source attributes from index object
108135
source_attrs = None
109136
if hasattr(index_obj, "getIndexSourceNames"):
@@ -136,7 +163,12 @@ def register(self, name, idx_type, idx_key, source_attrs=None):
136163
idx_key: JSONB key (usually same as name)
137164
source_attrs: list of attribute names for extraction
138165
(defaults to [idx_key])
166+
167+
Raises:
168+
ValueError: if idx_key is not a safe SQL identifier
139169
"""
170+
if idx_key is not None:
171+
validate_identifier(idx_key)
140172
if source_attrs is None:
141173
source_attrs = [idx_key] if idx_key is not None else [name]
142174
self._indexes[name] = (idx_type, idx_key, source_attrs)

src/plone/pgcatalog/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def release_request_connection(event=None):
179179
try:
180180
pool.putconn(conn)
181181
except Exception:
182-
pass # pool closed or conn already returned
182+
log.warning("Failed to return connection to pool", exc_info=True)
183183
_local.pgcat_conn = None
184184
_local.pgcat_pool = None
185185

src/plone/pgcatalog/dri.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class DateRecurringIndexTranslator:
6767
"""
6868

6969
def __init__(self, date_attr, recurdef_attr, until_attr=""):
70+
from plone.pgcatalog.columns import validate_identifier
71+
72+
validate_identifier(date_attr)
7073
self.date_attr = date_attr
7174
self.recurdef_attr = recurdef_attr
7275
self.until_attr = until_attr

src/plone/pgcatalog/interfaces.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ class IPGIndexTranslator(Interface):
2020
a fallback when the index is not in the ``IndexRegistry``.
2121
- ``catalog.py``: ``_extract_from_translators()`` calls ``extract()``
2222
for all registered translators during indexing.
23+
24+
**Security contract:**
25+
26+
Implementations MUST use psycopg parameterized queries for all
27+
user-supplied values. The ``query()`` method returns a raw SQL
28+
fragment that is appended directly to the WHERE clause — never
29+
interpolate user input into this fragment. Only use ``%(name)s``
30+
placeholders with corresponding entries in the returned params dict.
31+
Index/column identifiers in the fragment should be hardcoded constants
32+
or validated against ``columns.validate_identifier()``.
2333
"""
2434

2535
def extract(obj, index_name):
@@ -32,6 +42,10 @@ def extract(obj, index_name):
3242
def query(index_name, query_value, query_options):
3343
"""Translate a ZCatalog query dict into a SQL fragment + params.
3444
45+
The returned SQL fragment is inserted directly into a WHERE clause.
46+
All user-supplied values MUST use ``%(name)s`` parameter placeholders
47+
— never string-format values into the SQL.
48+
3549
Returns (sql_fragment, params_dict), e.g.:
3650
("pgcatalog_to_timestamptz(idx->>'event_start') <= %(drir_date)s",
3751
{"drir_date": query_value})
@@ -40,5 +54,8 @@ def query(index_name, query_value, query_options):
4054
def sort(index_name):
4155
"""Return SQL expression for ORDER BY, or None if not sortable.
4256
57+
The returned expression is inserted directly into an ORDER BY clause.
58+
Only use hardcoded column references — never interpolate user input.
59+
4360
Returns e.g.: "pgcatalog_to_timestamptz(idx->>'event_start')"
4461
"""

src/plone/pgcatalog/query.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
# Path validation pattern
3838
_PATH_RE = re.compile(r"^/[a-zA-Z0-9._/@+\-]*$")
3939

40+
# Maximum number of paths in a single path query (DoS prevention)
41+
_MAX_PATHS = 100
42+
4043

4144
def _lookup_translator(name):
4245
"""Look up an IPGIndexTranslator utility for a given index name.
@@ -110,15 +113,16 @@ def apply_security_filters(query_dict, roles, show_inactive=False):
110113
return result
111114

112115

113-
def execute_query(conn, query_dict, columns="zoid, path, idx, state"):
116+
def _execute_query(conn, query_dict, columns="zoid, path, idx, state"):
114117
"""Execute a catalog query and return result rows.
115118
116-
Convenience function that builds SQL from a query dict and executes it.
119+
Internal convenience function for testing. The ``columns`` parameter
120+
is interpolated into SQL, so callers must only pass trusted constants.
117121
118122
Args:
119123
conn: psycopg connection (with dict_row factory)
120124
query_dict: ZCatalog-style query dict
121-
columns: SQL column list to SELECT
125+
columns: SQL column list to SELECT (must be a trusted constant)
122126
123127
Returns:
124128
list of row dicts
@@ -411,6 +415,11 @@ def _handle_path(self, name, idx_key, spec):
411415

412416
paths = [query_val] if isinstance(query_val, str) else list(query_val)
413417

418+
if len(paths) > _MAX_PATHS:
419+
raise ValueError(
420+
f"Too many paths in query ({len(paths)}), maximum is {_MAX_PATHS}"
421+
)
422+
414423
for path in paths:
415424
_validate_path(path)
416425

src/plone/pgcatalog/schema.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
-- The built-in ::timestamptz cast is STABLE (depends on session timezone),
2222
-- but our ISO 8601 dates always include timezone info, making the result
2323
-- deterministic. This wrapper lets PG accept it in index expressions.
24+
--
25+
-- SECURITY NOTE: declared IMMUTABLE despite wrapping a STABLE cast.
26+
-- Safe because all stored dates include explicit timezone info (ISO 8601).
27+
-- If a value without timezone were stored, the result would depend on the
28+
-- session timezone setting and PG could cache incorrect index entries.
29+
-- Ensure all date values written to idx JSONB include timezone offsets.
2430
CREATE OR REPLACE FUNCTION pgcatalog_to_timestamptz(text)
2531
RETURNS timestamptz AS $$
2632
SELECT $1::timestamptz;

tests/test_fulltext.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from plone.pgcatalog.indexing import catalog_object
7-
from plone.pgcatalog.query import execute_query
7+
from plone.pgcatalog.query import _execute_query as execute_query
88
from tests.conftest import insert_object
99

1010

tests/test_path.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from plone.pgcatalog.indexing import catalog_object
8-
from plone.pgcatalog.query import execute_query
8+
from plone.pgcatalog.query import _execute_query as execute_query
99
from tests.conftest import insert_object
1010

1111

tests/test_query_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime
88
from datetime import UTC
99
from plone.pgcatalog.indexing import catalog_object
10-
from plone.pgcatalog.query import execute_query
10+
from plone.pgcatalog.query import _execute_query as execute_query
1111
from tests.conftest import insert_object
1212

1313

0 commit comments

Comments
 (0)