Skip to content

Commit 1d89791

Browse files
authored
Merge pull request from GHSA-rpcg-f9q6-2mq6
* - Prevent traversal through authorized Python modules in TAL expressions * - try a more generic solution that doesn't use special casing * - add suggestions from Maurits * - integrate remaining suggestions from Maurits
1 parent 15e521b commit 1d89791

6 files changed

Lines changed: 116 additions & 23 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ https://github.com/zopefoundation/Zope/blob/4.x/CHANGES.rst
1111
5.2.1 (unreleased)
1212
------------------
1313

14-
- Update to newest compatible versions of dependencies.
14+
- Prevent unauthorized traversal through authorized Python modules in
15+
TAL expressions
1516

1617
- Facelift the Zope logo.
1718
(`#973 <https://github.com/zopefoundation/Zope/issues/973>`_)
1819

20+
- Update to newest compatible versions of dependencies.
21+
1922

2023
5.2 (2021-05-21)
2124
----------------

src/OFS/zpt/main.zpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<main class="container-fluid">
66
<form id="objectItems" name="objectItems" method="post"
77
tal:define="has_order_support python:getattr(here.aq_explicit, 'has_order_support', 0);
8-
sm modules/AccessControl/SecurityManagement/getSecurityManager;
8+
sm modules/AccessControl/getSecurityManager;
99
default_sort python: 'position' if has_order_support else 'id';
1010
skey python:request.get('skey',default_sort);
1111
rkey python:request.get('rkey','asc');

src/Products/PageTemplates/Expressions.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import OFS.interfaces
2323
from AccessControl import safe_builtins
24+
from AccessControl.SecurityManagement import getSecurityManager
2425
from Acquisition import aq_base
2526
from MultiMapping import MultiMapping
2627
from zExceptions import NotFound
@@ -70,24 +71,43 @@ def boboAwareZopeTraverse(object, path_items, econtext):
7071
necessary (bobo-awareness).
7172
"""
7273
request = getattr(econtext, 'request', None)
74+
validate = getSecurityManager().validate
7375
path_items = list(path_items)
7476
path_items.reverse()
7577

7678
while path_items:
7779
name = path_items.pop()
7880

79-
if name == '_':
80-
warnings.warn('Traversing to the name `_` is deprecated '
81-
'and will be removed in Zope 6.',
82-
DeprecationWarning)
83-
elif name.startswith('_'):
84-
raise NotFound(name)
85-
8681
if OFS.interfaces.ITraversable.providedBy(object):
8782
object = object.restrictedTraverse(name)
8883
else:
89-
object = traversePathElement(object, name, path_items,
90-
request=request)
84+
found = traversePathElement(object, name, path_items,
85+
request=request)
86+
87+
# Special backwards compatibility exception for the name ``_``,
88+
# which was often used for translation message factories.
89+
# Allow and continue traversal.
90+
if name == '_':
91+
warnings.warn('Traversing to the name `_` is deprecated '
92+
'and will be removed in Zope 6.',
93+
DeprecationWarning)
94+
object = found
95+
continue
96+
97+
# All other names starting with ``_`` are disallowed.
98+
# This emulates what restrictedTraverse does.
99+
if name.startswith('_'):
100+
raise NotFound(name)
101+
102+
# traversePathElement doesn't apply any Zope security policy,
103+
# so we validate access explicitly here.
104+
try:
105+
validate(object, object, name, found)
106+
object = found
107+
except Unauthorized:
108+
# Convert Unauthorized to prevent information disclosures
109+
raise NotFound(name)
110+
91111
return object
92112

93113

src/Products/PageTemplates/expression.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from chameleon.tales import NotExpr
1111
from chameleon.tales import StringExpr
1212

13+
from AccessControl.SecurityManagement import getSecurityManager
1314
from AccessControl.ZopeGuards import guarded_apply
1415
from AccessControl.ZopeGuards import guarded_getattr
1516
from AccessControl.ZopeGuards import guarded_getitem
@@ -57,24 +58,49 @@ class BoboAwareZopeTraverse:
5758
def traverse(cls, base, request, path_items):
5859
"""See ``zope.app.pagetemplate.engine``."""
5960

61+
validate = getSecurityManager().validate
6062
path_items = list(path_items)
6163
path_items.reverse()
6264

6365
while path_items:
6466
name = path_items.pop()
6567

66-
if name == '_':
67-
warnings.warn('Traversing to the name `_` is deprecated '
68-
'and will be removed in Zope 6.',
69-
DeprecationWarning)
70-
elif name.startswith('_'):
71-
raise NotFound(name)
72-
7368
if ITraversable.providedBy(base):
7469
base = getattr(base, cls.traverse_method)(name)
7570
else:
76-
base = traversePathElement(base, name, path_items,
77-
request=request)
71+
found = traversePathElement(base, name, path_items,
72+
request=request)
73+
74+
# If traverse_method is something other than
75+
# ``restrictedTraverse`` then traversal is assumed to be
76+
# unrestricted. This emulates ``unrestrictedTraverse``
77+
if cls.traverse_method != 'restrictedTraverse':
78+
base = found
79+
continue
80+
81+
# Special backwards compatibility exception for the name ``_``,
82+
# which was often used for translation message factories.
83+
# Allow and continue traversal.
84+
if name == '_':
85+
warnings.warn('Traversing to the name `_` is deprecated '
86+
'and will be removed in Zope 6.',
87+
DeprecationWarning)
88+
base = found
89+
continue
90+
91+
# All other names starting with ``_`` are disallowed.
92+
# This emulates what restrictedTraverse does.
93+
if name.startswith('_'):
94+
raise NotFound(name)
95+
96+
# traversePathElement doesn't apply any Zope security policy,
97+
# so we validate access explicitly here.
98+
try:
99+
validate(base, base, name, found)
100+
base = found
101+
except Unauthorized:
102+
# Convert Unauthorized to prevent information disclosures
103+
raise NotFound(name)
78104

79105
return base
80106

src/Products/PageTemplates/tests/testExpressions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from AccessControl import safe_builtins
55
from zExceptions import NotFound
66
from zope.component.testing import PlacelessSetup
7+
from zope.location.interfaces import LocationError
78

89

910
class EngineTestsBase(PlacelessSetup):
@@ -233,10 +234,10 @@ def test_underscore_traversal(self):
233234
with self.assertRaises(NotFound):
234235
ec.evaluate("context/__class__")
235236

236-
with self.assertRaises(NotFound):
237+
with self.assertRaises((NotFound, LocationError)):
237238
ec.evaluate("nocall: random/_itertools/repeat")
238239

239-
with self.assertRaises(NotFound):
240+
with self.assertRaises((NotFound, LocationError)):
240241
ec.evaluate("random/_itertools/repeat/foobar")
241242

242243

src/Products/PageTemplates/tests/testHTMLTests.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
DefaultUnicodeEncodingConflictResolver
2727
from Products.PageTemplates.unicodeconflictresolver import \
2828
PreferredCharsetResolver
29+
from Products.PageTemplates.ZopePageTemplate import ZopePageTemplate
2930
from zExceptions import NotFound
3031
from zope.component import provideUtility
32+
from zope.location.interfaces import LocationError
3133
from zope.traversing.adapters import DefaultTraversable
3234

3335
from .util import useChameleonEngine
@@ -37,6 +39,10 @@ class AqPageTemplate(Implicit, PageTemplate):
3739
pass
3840

3941

42+
class AqZopePageTemplate(Implicit, ZopePageTemplate):
43+
pass
44+
45+
4046
class Folder(util.Base):
4147
pass
4248

@@ -74,6 +80,7 @@ def setUp(self):
7480
self.folder = f = Folder()
7581
f.laf = AqPageTemplate()
7682
f.t = AqPageTemplate()
83+
f.z = AqZopePageTemplate('testing')
7784
self.policy = UnitTestSecurityPolicy()
7885
self.oldPolicy = SecurityManager.setSecurityPolicy(self.policy)
7986
noSecurityManager() # Use the new policy.
@@ -226,9 +233,45 @@ def test_underscore_traversal(self):
226233
t()
227234

228235
t.write('<p tal:define="p nocall: random/_itertools/repeat"/>')
229-
with self.assertRaises(NotFound):
236+
with self.assertRaises((NotFound, LocationError)):
230237
t()
231238

232239
t.write('<p tal:content="random/_itertools/repeat/foobar"/>')
240+
with self.assertRaises((NotFound, LocationError)):
241+
t()
242+
243+
def test_module_traversal(self):
244+
t = self.folder.z
245+
246+
# Need to reset to the standard security policy so AccessControl
247+
# checks are actually performed. The test setup initializes
248+
# a policy that circumvents those checks.
249+
SecurityManager.setSecurityPolicy(self.oldPolicy)
250+
noSecurityManager()
251+
252+
# The getSecurityManager function is explicitly allowed
253+
content = ('<p tal:define="a nocall:%s"'
254+
' tal:content="python: a().getUser().getUserName()"/>')
255+
t.write(content % 'modules/AccessControl/getSecurityManager')
256+
self.assertEqual(t(), '<p>Anonymous User</p>')
257+
258+
# Anything else should be unreachable and raise NotFound:
259+
# Direct access through AccessControl
260+
t.write('<p tal:define="a nocall:modules/AccessControl/users"/>')
261+
with self.assertRaises(NotFound):
262+
t()
263+
264+
# Indirect access through an intermediary variable
265+
content = ('<p tal:define="mod nocall:modules/AccessControl;'
266+
' must_fail nocall:mod/users"/>')
267+
t.write(content)
268+
with self.assertRaises(NotFound):
269+
t()
270+
271+
# Indirect access through an intermediary variable and a dictionary
272+
content = ('<p tal:define="mod nocall:modules/AccessControl;'
273+
' a_dict python: {\'unsafe\': mod};'
274+
' must_fail nocall: a_dict/unsafe/users"/>')
275+
t.write(content)
233276
with self.assertRaises(NotFound):
234277
t()

0 commit comments

Comments
 (0)