I ran into an unexpected issue with CSRF while updating an existing form on my Plone 6 classic site. In my new setup, plone.protect sometimes requires me to confirm my action. I found a workaround, but am very curious about what causes this issue...
Updated: this seems to be related to writing the token and new expiry time to the portal registry.
My flow involves calling a Pythonscript dispatcher edi-order
that shows a form. The contents of the form depend on how the url is called:
http://localhost:8888/Plone/api/edi-order/2094026
http://localhost:8888/Plone/api/edi-order/2741813
Each will populate the form with different values, depending on the hashed value at the end of the URL.
In the form is a DataGridField with rows that contain a choice field that will be populated by way of a RestAPI call to a Plone 5 instance. This setup has worked fine, without ever requiring any CSRF confirmation, using requests
directly in my function. This, unfortunately relies on including the username and password in each request.
rest_utils.py
@cache(_cache_key_rest_results)
def get_rest_results(url='', target_language='de', query={}, default_payload = True):
""" """
headers['Accept-Language'] = target_language
headers['X-CSRF-TOKEN'] = createToken()
payload = default_payload and {
'Language' : target_language ,
'metadata_fields' : '_all' ,
'sort_on' : 'sortable_title' ,
} or {}
for key in query:
payload[key] = query[key]
results = requests.get(url=url, params=payload, headers=headers, auth=auth)
content = results.json()
return content
In my new setup, I created a controlpanel entry to store the URL and credentials for the resource, and decided to use JWT. For this, I created a connection manager class that is registered as an utility and takes care of getting or refreshing the token as needed, and supplying the content when called, e.g.
configure.zcml
<utility
name="rest_session_client"
provides="pnz.erpediem.client.interfaces.IRestSessionClient"
factory="pnz.erpediem.client.plonerest.RestSessionManager"/>
rest_utils.py
@cache(_cache_key_rest_results)
def get_rest_session_results(url='', target_language='de', query={}, default_payload = True):
""" get a JSON result using the plonerest IRestSessionClient
"""
rest_session_client = getUtility(IRestSessionClient, name='rest_session_client')
content = rest_session_client(url=url, target_language=target_language, query=query, default_payload = True)
return content
In the session manager, I have a function get_content()
that essentially is identical to what I used directly before:
plonerest.py
def get_content(self, url='', target_language='de', query={}, default_payload = True):
""" return JSON content"""
content = {}
self.session.headers.update({
'Authorization': f'Bearer {self.token}',
'X-CSRF-TOKEN': createToken(),
})
log.info('Updated CSRF and Auth headers') # %s' % self.session.headers)
payload = default_payload and {
'Language': target_language,
'metadata_fields': '_all',
'sort_on': 'sortable_title',
} or {}
for key in query:
payload[key] = query[key]
response = self.session.get(
url=url,
params=payload,
headers=self.session.headers,
)
if response.status_code == 200:
content = response.json()
return content
I fail to understand what causes the CSRF protection to trigger. I found a workaround by letting my dispatcher redirect to itself to fixup the URL that is mangled by the plone.protect script, such as:
edi_order PythonScript
""" return the view, perform validation of the form or execute the vocabulary query
based on request traverse_subpath and the form customer_hash
"""
request = container.REQUEST
response = request.response
lang = str(request.LANGUAGE)
site_prefix ='/Plone/'
view_name = '/@@create_edi_order'
if traverse_subpath:
if len(traverse_subpath) == 1:
try:
if traverse_subpath[0] == '@@getVocabulary':
obj = request.PARENTS[0].restrictedTraverse(site_prefix + '/'.join(traverse_subpath))
else:
request.form['customer_hash'] = int(traverse_subpath[0])
obj = request.PARENTS[0].restrictedTraverse(site_prefix + lang + view_name)
return obj()
except ValueError:
pass
elif traverse_subpath[0].startswith('++resource++'):
server_url = request.SERVER_URL
if site_prefix in server_url:
url = server_url + '/' + '/'.join(traverse_subpath)
else:
url = server_url + site_prefix + '/'.join(traverse_subpath)
return response.redirect(url, status="301")
elif traverse_subpath[-1] == '@@z3cform_validate_field':
obj = request.PARENTS[0].restrictedTraverse(site_prefix + lang + view_name + '/@@z3cform_validate_field' )
return obj()
elif traverse_subpath[-1] == '@@autocomplete-search':
obj = request.PARENTS[0].unrestrictedTraverse(site_prefix + lang + view_name + '/'.join(traverse_subpath))
return obj()
if 'customer_hash' in request.form:
url = '%s/%s' % (request['ACTUAL_URL'], request.form['customer_hash'])
return response.redirect(url, status="302")
response.setStatus(403)
return '403 Forbidden - ' + '/'.join(traverse_subpath)
#print request
#return printed
Finally, the error occurs after the Rest call completes successfully (several times) and my vocabulary has been populated:
2024-10-08 13:36:57,780 INFO [pnz.erpediem.client.vocabularies.mdb:40][waitress-0] Vocabulary factory customer_hash: 3527421
2024-10-08 13:36:57,780 INFO [pnz.erpediem.client.utilities.rest_utils:64][waitress-0] REST results is using cache cache-rest_results-57f1017d551a51859b642157cadea634-480107.0
2024-10-08 13:36:57,780 INFO [pnz.erpediem.client.vocabularies.mdb:55][waitress-0] OrderItemsVocabulary populated with terms: [{'title': 'No Value', 'token': '--NOVALUE--', 'value': '--NOVALUE--'}, {'title': '14151 BaStone Allround Holz Lasur birkenweiß 0,75 Liter', 'token': '14151', 'value': '14151'}]
2024-10-08 13:36:57,781 INFO [pnz.erpediem.client.vocabularies.mdb:40][waitress-0] Vocabulary factory customer_hash: 3527421
2024-10-08 13:36:57,781 INFO [pnz.erpediem.client.utilities.rest_utils:64][waitress-0] REST results is using cache cache-rest_results-57f1017d551a51859b642157cadea634-480107.0
2024-10-08 13:36:57,781 INFO [pnz.erpediem.client.vocabularies.mdb:55][waitress-0] OrderItemsVocabulary populated with terms: [{'title': 'No Value', 'token': '--NOVALUE--', 'value': '--NOVALUE--'}, {'title': '14151 BaStone Allround Holz Lasur birkenweiß 0,75 Liter', 'token': '14151', 'value': '14151'}]
2024-10-08 13:36:58,286 INFO [plone.protect:283][waitress-0] File "/usr/lib/python3.11/threading.py", line 1002, in _bootstrap
self._bootstrap_inner()
File "/usr/lib/python3.11/threading.py", line 1045, in _bootstrap_inner
self.run()
File "/usr/lib/python3.11/threading.py", line 982, in run
self._target(*self._args, **self._kwargs)
File "/usr/local/Plone6/lib/python3.11/site-packages/waitress/task.py", line 84, in handler_thread
task.service()
File "/usr/local/Plone6/lib/python3.11/site-packages/waitress/channel.py", line 428, in service
task.service()
File "/usr/local/Plone6/lib/python3.11/site-packages/waitress/task.py", line 168, in service
self.execute()
File "/usr/local/Plone6/lib/python3.11/site-packages/waitress/task.py", line 434, in execute
app_iter = self.channel.server.application(environ, start_response)
File "/usr/local/Plone6/lib/python3.11/site-packages/paste/translogger.py", line 77, in __call__
return self.application(environ, replacement_start_response)
File "/usr/local/Plone6/lib/python3.11/site-packages/ZPublisher/httpexceptions.py", line 30, in __call__
return self.application(environ, start_response)
File "/usr/local/Plone6/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py", line 389, in publish_module
with transaction_pubevents(request, response):
File "/usr/lib/python3.11/contextlib.py", line 144, in __exit__
next(self.gen)
File "/usr/local/Plone6/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py", line 183, in transaction_pubevents
notify(pubevents.PubBeforeCommit(request))
File "/usr/local/Plone6/lib/python3.11/site-packages/zope/event/__init__.py", line 33, in notify
subscriber(event)
File "/usr/local/Plone6/lib/python3.11/site-packages/zope/component/event.py", line 27, in dispatch
component_subscribers(event, None)
File "/usr/local/Plone6/lib/python3.11/site-packages/zope/component/_api.py", line 146, in subscribers
return sitemanager.subscribers(objects, interface)
File "/usr/local/Plone6/lib/python3.11/site-packages/zope/interface/registry.py", line 445, in subscribers
return self.adapters.subscribers(objects, provided)
File "/usr/local/Plone6/lib/python3.11/site-packages/zope/interface/adapter.py", line 896, in subscribers
subscription(*objects)
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/transformchain/zpublisher.py", line 75, in applyTransformOnSuccess
transformed = applyTransform(event.request)
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/transformchain/zpublisher.py", line 65, in applyTransform
transformed = transformer(request, result, encoding)
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/transformchain/transformer.py", line 50, in __call__
newResult = handler.transformIterable(result, encoding)
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/protect/auto.py", line 180, in transformIterable
if not self.check():
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/protect/auto.py", line 205, in check
return self._check()
File "/usr/local/Plone6/lib/python3.11/site-packages/plone/protect/auto.py", line 286, in _check
"\n".join(traceback.format_stack()), self.request.URL
aborting transaction due to no CSRF protection on url http://localhost:8888/Plone/api/edi-order