diff test/test_cgi.py @ 8583:037725ac7161

test: add testing for handle_csrf_tokenless() Add a dozen tests that I think cover the code.
author John Rouillard <rouilj@ieee.org>
date Mon, 20 Apr 2026 01:53:09 -0400
parents 5cba36e42b8f
children 31a8a6faa2fa
line wrap: on
line diff
--- a/test/test_cgi.py	Mon Apr 20 01:41:54 2026 -0400
+++ b/test/test_cgi.py	Mon Apr 20 01:53:09 2026 -0400
@@ -218,6 +218,10 @@
         self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes,
             re.VERBOSE)
 
+    @pytest.fixture(autouse=True)
+    def inject_fixtures(self, caplog):
+        self._caplog = caplog
+
     #
     # form label extraction
     #
@@ -993,6 +997,256 @@
         self.assertFalse('HTTP_PROXY' in cl.env)
         self.assertFalse('HTTP_PROXY' in os.environ)
 
+    def testTokenlessCsrfProtection(self):
+        # need to set SENDMAILDEBUG to prevent
+        # downstream issue when email is sent on successful
+        # issue creation. Also delete the file afterwards
+        # just to make sure that some other test looking for
+        # SENDMAILDEBUG won't trip over ours.
+        if 'SENDMAILDEBUG' not in os.environ:
+            os.environ['SENDMAILDEBUG'] = 'mail-test1.log'
+        SENDMAILDEBUG = os.environ['SENDMAILDEBUG']
+
+        page_template = """
+        <html>
+         <body>
+          <p tal:condition="options/error_message|nothing"
+             tal:repeat="m options/error_message"
+             tal:content="structure m"/>
+          <p tal:content="context/title/plain"/>
+          <p tal:content="context/priority/plain"/>
+          <p tal:content="context/status/plain"/>
+          <p tal:content="context/nosy/plain"/>
+          <p tal:content="context/keyword/plain"/>
+          <p tal:content="structure context/superseder/field"/>
+         </body>
+        </html>
+        """.strip ()
+        self.db.keyword.create (name = 'key1')
+        self.db.keyword.create (name = 'key2')
+        nodeid = self.db.issue.create (title = 'Title', priority = '1',
+            status = '1', nosy = ['1'], keyword = ['1'])
+        self.db.commit ()
+        form = {':note': 'msg-content', 'title': 'New title',
+            'priority': '2', 'status': '2', 'nosy': '1,2', 'keyword': '',
+            ':action': 'edit'}
+
+        pt = RoundupPageTemplate()
+        pt.pt_edit(page_template, 'text/html')
+        out = []
+        def wh(s):
+            out.append(s)
+
+        cl = self.setupClient(form, 'issue', '1')
+        cl.db.config['WEB_USE_TOKENLESS_CSRF_PROTECTION'] = 'yes'
+        cl.write_html = wh
+
+        # Enable the following if we get a templating error:
+        #def send_error (*args, **kw):
+        #    import pdb; pdb.set_trace()
+        #cl.send_error_to_admin = send_error
+        # Need to rollback the database on error -- this usually happens
+        # in web-interface (and for other databases) anyway, need it for
+        # testing that the form values are really used, not the database!
+        # We do this together with the setup of the easy template above
+        def load_template(x):
+            cl.db.rollback()
+            return pt
+        cl.instance.templates.load = load_template
+        cl.selectTemplate = MockNull()
+        cl.determine_context = MockNull ()
+        def hasPermission(s, p, classname=None, d=None, e=None, **kw):
+            return True
+        actions.Action.hasPermission = hasPermission
+        orig_HTMLItem_is_edit_ok = _HTMLItem.is_edit_ok
+        e1 = _HTMLItem.is_edit_ok
+        _HTMLItem.is_edit_ok = lambda x : True
+        e2 = HTMLProperty.is_edit_ok
+        orig_HTMLProperty_is_edit_ok = HTMLProperty.is_edit_ok
+        HTMLProperty.is_edit_ok = lambda x : True
+
+        # If Result is not "Unable to authorize request", the CSRF check
+        # passed. Since we are using a form that specified the edit action,
+        # GET, HEAD, OPTIONS should all fail with invalid request as edit
+        # action requires POST.
+        test_table = [
+            { 
+                # Case 1: test with no security headers.
+                # Should get a redirect and create an issue since
+                # browsers will set either Origin or Sec-Fetch-Site. So
+                # request not from a browser and we allow it.
+                "Request_Method": "POST",
+                "HTTP_Origin": None,
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": None,
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20'),
+            },
+            {
+                # Case 2: POST should succeed with base origin.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://whoami.com",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "whoami.com",
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20')
+            },
+            {
+                # Case 3: POST should fail due to bad origin.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "whoami.com",
+                "Result": "Unable to authorize request"
+            },
+            {
+                # Case 4: POST should succeed with added origin.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "whoami.com",
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20'),
+                'allowed_api_origins': "* https://whatRU.com https://foo.bar" 
+            },
+            {
+                # Case 5: GET should get to action and be rejected
+                #   for editing with a GET.
+                "Request_Method": "GET",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "whoami.com",
+                "Result": 'Invalid request',
+            },
+            {
+                # Case 6: HEAD should get to action and be rejected
+                #   for editing with a GET.
+                "Request_Method": "HEAD",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "whoami.com",
+                "Result": 'Invalid request',
+            },
+            {
+                # Case 7: POST should succeed due to fetch same-origin.
+                #  this combination should never be sent by a browser.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": "same-origin",
+                "HTTP_Host": "whoami.com",
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20'),
+            },
+            {
+                # Case 8: POST should fail due to fetch same-site.
+                #  this combination should never be sent by a browser.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": "same-site",
+                "HTTP_Host": "whoami.com",
+                "Result": "Unable to authorize request",
+            },
+            {
+                # Case 9: POST should suceed due to fetch none.
+                #  (user triggered browser bookmark)
+                "Request_Method": "POST",
+                "HTTP_Origin": None,
+                "HTTP_Sec_Fetch_Site": "none",
+                "HTTP_Host": "whoami.com",
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20'),
+            },
+            {
+                # Case 10: POST should fail due to fetch same-site.
+                #  this combination should never be sent by a browser.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": "same-site",
+                "HTTP_Host": "foo.bar",
+                "Result": "Unable to authorize request",
+            },
+            {
+                # Case 11: UNLOCK should fail, unsupported method
+                "Request_Method": "UNLOCK",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": "same-site",
+                "HTTP_Host": "foo.bar",
+                "Result": "Bad Request: UNLOCK",
+            },
+            {
+                # Case 12: POST should pass csrf because origin's host
+                # component and HOST match.
+                "Request_Method": "POST",
+                "HTTP_Origin": "https://foo.bar",
+                "HTTP_Sec_Fetch_Site": None,
+                "HTTP_Host": "foo.bar",
+                "Result": ('Redirecting to '
+                           '<a href="http://whoami.com/path/issue1'
+                           '?@ok_message=msg%20'),
+            },
+        ]
+
+        for entry, test in enumerate(test_table):
+            print("Running test", entry)
+            if 'PATH_INFO' in test:
+                cl.env = {"PATH_INFO": test['PATH_INFO']}
+            else:
+                cl.env = {"PATH_INFO": "/"}
+            for header in ["Request_Method", "HTTP_Origin",
+                           "HTTP_Sec_Fetch_Site", "HTTP_Host"]:
+                if test[header]:
+                    cl.env[header.upper()] = test[header]
+            if 'allowed_api_origins' in test:
+                cl.db.config['WEB_ALLOWED_API_ORIGINS'] = test[
+                    'allowed_api_origins']
+            else:
+                cl.db.config['WEB_ALLOWED_API_ORIGINS'] = ""
+
+            cl.main()
+            self.assertIn(test['Result'], out[0])
+
+            del(out[0])
+
+        # get request with nonce
+        cl.env = {"PATH_INFO": "/"}
+        # make sure that a get deletes the csrf.
+        cl.env['REQUEST_METHOD'] = 'GET'
+        cl.env['HTTP_REFERER'] = "test routine"
+
+        # used to test deletion of exposed nonces.
+        # must reset tokenless setting so we get a real nonce and not 0.
+        cl.db.config['WEB_USE_TOKENLESS_CSRF_PROTECTION'] = 'no'
+        real_nonce = anti_csrf_nonce(cl)
+        cl.db.config['WEB_USE_TOKENLESS_CSRF_PROTECTION'] = 'yes'
+
+        form2 = copy.copy(form)
+        form2.update({'@csrf': real_nonce})
+
+        # add a real csrf field to the form and rerun main
+        cl.form = db_test_base.makeForm(form2)
+        cl.main()
+
+        # csrf passes but fail creating new issue because not a post
+        match_at=out[0].find('<p>Invalid request</p>')
+        self.assertEqual(match_at, 33)
+
+        # verify record was logged.
+        self.assertEqual( 'csrf key used with method GET from: '
+                          'Referer(test routine)',
+                          self._caplog.messages[0])
+
+        # verify nonce is 0 when tokenless protection enabled.
+        self.assertEqual("0", anti_csrf_nonce(cl))
+        
+        # clean up from email log
+        if os.path.exists(SENDMAILDEBUG):
+            os.remove(SENDMAILDEBUG)
+
     def testCsrfProtectionHtml(self):
         # need to set SENDMAILDEBUG to prevent
         # downstream issue when email is sent on successful

Roundup Issue Tracker: http://roundup-tracker.org/