diff test/test_cgi.py @ 7155:89a59e46b3af

improve REST interface security When using REST, we reflect the client's origin. If the wildcard '*' is used in allowed_api_origins all origins are allowed. When this is done, it also added an 'Access-Control-Allow-Credentials: true' header. This Credentials header should not be added if the site is matched only by '*'. This header should be provided only for explicit origins (e.g. https://example.org) not for the wildcard. This is now fixed for CORS preflight OPTIONS request as well as normal GET, PUT, DELETE, POST, PATCH and OPTIONS requests. A missing Access-Control-Allow-Credentials will prevent the tracker from being accessed using credentials. This prevents an unauthorized third party web site from using a user's credentials to access information in the tracker that is not publicly available. Added test for this specific case. In addition, allowed_api_origins can include explicit origins in addition to '*'. '*' must be first in the list. Also adapted numerous tests to work with these changes. Doc updates.
author John Rouillard <rouilj@ieee.org>
date Thu, 23 Feb 2023 12:01:33 -0500
parents f614176903d0
children ed63b6d35838
line wrap: on
line diff
--- a/test/test_cgi.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/test/test_cgi.py	Thu Feb 23 12:01:33 2023 -0500
@@ -1190,8 +1190,7 @@
             os.remove(SENDMAILDEBUG)
         #raise ValueError
 
-    @pytest.mark.xfail
-    def testRestOriginValidation(self):
+    def testRestOriginValidationCredentials(self):
         import json
         # set the password for admin so we can log in.
         passwd=password.Password('admin')
@@ -1251,6 +1250,72 @@
         del(out[0])
 
 
+        # Origin not set. AKA same origin GET request.
+        # Should be like valid origin.
+        # Because of HTTP_X_REQUESTED_WITH header it should be
+        # preflighted.
+        cl = client.Client(self.instance, None,
+                           {'REQUEST_METHOD':'GET',
+                            'PATH_INFO':'rest/data/issue',
+                            'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=',
+                            'HTTP_REFERER': 'http://whoami.com/path/',
+                            'HTTP_ACCEPT': "application/json;version=1",
+                            'HTTP_X_REQUESTED_WITH': 'rest',
+                        }, form)
+        cl.db = self.db
+        cl.base = 'http://whoami.com/path/'
+        cl._socket_op = lambda *x : True
+        cl._error_message = []
+        cl.request = MockNull()
+        h = { 'content-type': 'application/json',
+              'accept': 'application/json' }
+        cl.request.headers = MockNull(**h)
+                                      
+        cl.write = wh # capture output
+
+        # Should return explanation because content type is text/plain
+        # and not text/xml
+        cl.handle_rest()
+        self.assertIn('Access-Control-Allow-Credentials',
+                      cl.additional_headers)
+
+        self.assertEqual(json.loads(b2s(out[0])),json.loads(expected))
+        del(out[0])
+
+        cl = client.Client(self.instance, None,
+                           {'REQUEST_METHOD':'OPTIONS',
+                            'HTTP_ORIGIN': 'http://invalid.com',
+                            'PATH_INFO':'rest/data/issue',
+                            'Access-Control-Request-Headers': 'Authorization',
+                            'Access-Control-Request-Method': 'GET',
+                           }, form)
+        cl.db = self.db
+        cl.base = 'http://whoami.com/path/'
+        cl._socket_op = lambda *x : True
+        cl._error_message = []
+        cl.request = MockNull()
+        h = { 'content-type': 'application/json',
+              'accept': 'application/json',
+              'access-control-request-headers': 'Authorization',
+              'access-control-request-method': 'GET',
+        }
+        cl.request.headers = MockNull(**h)
+                                      
+        cl.write = wh # capture output
+
+        # Should return explanation because content type is text/plain
+        # and not text/xml
+        cl.handle_rest()
+        self.assertNotIn('Access-Control-Allow-Credentials',
+                      cl.additional_headers)
+
+        self.assertNotIn('Access-Control-Allow-Origin',
+            cl.additional_headers
+        )
+
+        self.assertEqual(cl.response_code, 400)
+        del(out[0])
+
         # origin not set to allowed value
         # prevents authenticated request like this from
         # being shared with the requestor because
@@ -1283,12 +1348,20 @@
         self.assertEqual(json.loads(b2s(out[0])),
                          json.loads(expected)
         )
-        self.assertNotIn('Access-Control-Allow-Credentials', cl.additional_headers)
+        self.assertNotIn('Access-Control-Allow-Credentials',
+                         cl.additional_headers)
+        self.assertIn('Access-Control-Allow-Origin',
+                      cl.additional_headers)
+        self.assertEqual(
+            h['origin'],
+            cl.additional_headers['Access-Control-Allow-Origin']
+        )
+
         self.assertIn('Content-Length', cl.additional_headers)
         del(out[0])
 
 
-        # origin not set. Same rules as for invalid origin
+        # CORS Same rules as for invalid origin
         cl = client.Client(self.instance, None,
                            {'REQUEST_METHOD':'GET',
                             'PATH_INFO':'rest/data/issue',
@@ -1311,16 +1384,18 @@
         # Should return explanation because content type is text/plain
         # and not text/xml
         cl.handle_rest()
-        self.assertNotIn('Access-Control-Allow-Credentials', cl.additional_headers)
+        self.assertIn('Access-Control-Allow-Credentials',
+                      cl.additional_headers)
 
         self.assertEqual(json.loads(b2s(out[0])),json.loads(expected))
         del(out[0])
 
-        # origin set to special "null" value. Same rules as for invalid origin
+        # origin set to special "null" value. Same rules as for
+        # invalid origin
         cl = client.Client(self.instance, None,
                            {'REQUEST_METHOD':'GET',
                             'PATH_INFO':'rest/data/issue',
-                            'ORIGIN': 'null',
+                            'HTTP_ORIGIN': 'null',
                             'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=',
                             'HTTP_REFERER': 'http://whoami.com/path/',
                             'HTTP_ACCEPT': "application/json;version=1",

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