Plone RestAPI - Registry and CSRF [SOLVED]

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:

http://localhost:8888/Plone/@@confirm-action?customer_hash=3527421&customer_id=K+0313&order_date=2024-10-08+11%3A36%3A57.786985&original_url=http%3A%2F%2Flocalhost%3A8888%2FPlone%2Fapi%2Fedi-order

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

Solution:

The problem was not in my PythonScript dispatcher but because of the new functionality I introduced to save the token and expiry time to the registry. I did not realize this was problematic, since I was already using plone.protect.authenticator createToken() in the Rest request itself...

I simply needed to add this to my function:

plonerest.py

    def save_token(self):
        """ save token to registry
            see https://stackoverflow.com/questions/6346391/componentlookuperror-when-querying-the-registry-at-startup-time
        """

        request = getRequest()
        alsoProvides(request, IDisableCSRFProtection)

        refresh_interval = api.portal.get_registry_record('plone_rest.refresh_interval')
        expires_at = datetime.now() + timedelta(hours=refresh_interval)

        api.portal.set_registry_record('plone_rest.jwt_token', self.token)
        api.portal.set_registry_record('plone_rest.token_expires', expires_at)

Hope this helps someone :joy: