diff test/rest_common.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 32c6e98e5a21
children 6f09103a6522
line wrap: on
line diff
--- a/test/rest_common.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/test/rest_common.py	Thu Feb 23 12:01:33 2023 -0500
@@ -233,13 +233,17 @@
 
         tx_Source_init(self.db)
 
-        env = {
+        self.client_env = {
             'PATH_INFO': 'http://localhost/rounduptest/rest/',
             'HTTP_HOST': 'localhost',
-            'TRACKER_NAME': 'rounduptest'
+            'TRACKER_NAME': 'rounduptest',
+            'HTTP_ORIGIN': 'http://tracker.example'
         }
-        self.dummy_client = client.Client(self.instance, MockNull(), env, [], None)
+        self.dummy_client = client.Client(self.instance, MockNull(),
+                                          self.client_env, [], None)
         self.dummy_client.request.headers.get = self.get_header
+        self.dummy_client.db = self.db
+
         self.empty_form = cgi.FieldStorage()
         self.terse_form = cgi.FieldStorage()
         self.terse_form.list = [
@@ -264,6 +268,8 @@
         try:
             return self.headers[header.lower()]
         except (AttributeError, KeyError, TypeError):
+            if header.upper() in self.client_env:
+                return self.client_env[header.upper()]
             return not_found
 
     def create_stati(self):
@@ -311,6 +317,7 @@
         Retrieve all three users
         obtain data for 'joe'
         """
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
         # Retrieve all three users.
         results = self.server.get_collection('user', self.empty_form)
         self.assertEqual(self.dummy_client.response_code, 200)
@@ -1082,6 +1089,7 @@
         for i in range(20):
             # i is 0 ... 19
             self.client_error_message = []
+            self.server.client.env.update({'REQUEST_METHOD': 'GET'})
             results = self.server.dispatch('GET',
                             "/rest/data/user/%s/realname"%self.joeid,
                             self.empty_form)
@@ -1318,6 +1326,8 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
+
         headers={"accept": "application/json; version=1",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": env['CONTENT_LENGTH'],
@@ -1360,6 +1370,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json; version=1",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": env['CONTENT_LENGTH'],
@@ -1383,6 +1394,7 @@
         self.assertEqual(json_dict['data']['link'],
                          "http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/issue/1")
         self.assertEqual(json_dict['data']['id'], "1")
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
         results = self.server.dispatch('GET',
                             "/rest/data/issue/1", self.empty_form)
         print(results)
@@ -1408,6 +1420,7 @@
         # simulate: /rest/data/issue
         env = { "REQUEST_METHOD": "DELETE"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json; version=1",
         }
         self.headers=headers
@@ -1441,6 +1454,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
 
         headers={"accept": "application/json; version=1",
                  "content-type": env['CONTENT_TYPE'],
@@ -1489,7 +1503,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
-
+        self.server.client.env.update(env)
         headers={"accept": "application/zot; version=1; q=0.5",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": env['CONTENT_LENGTH'],
@@ -1518,7 +1532,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
-
+        self.server.client.env.update(env)
         headers={"accept": "application/zot; version=1; q=0.75, "
                            "application/json; version=1; q=0.5",
                  "content-type": env['CONTENT_TYPE'],
@@ -1660,6 +1674,7 @@
         form.list = [
             cgi.MiniFieldStorage('@stats', 'False'),
         ]
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
         results = self.server.dispatch('GET',
                  "/rest/data/user/1/realname",
                                  form)
@@ -1717,6 +1732,7 @@
         self.headers = headers
         self.server.client.request.headers.get = self.get_header
         self.db.setCurrentUser('admin') # must be admin to change user
+        self.server.client.env.update({'REQUEST_METHOD': 'PUT'})
         results = self.server.dispatch('PUT',
                             "/rest/data/user/1/realname",
                             form)
@@ -1740,8 +1756,11 @@
         body=b'{ "data": "Joe Doe 1" }'
         env = { "CONTENT_TYPE": "application/json",
                 "CONTENT_LENGTH": len(body),
-                "REQUEST_METHOD": "PUT"
+                "REQUEST_METHOD": "PUT",
+                "HTTP_ORIGIN": "https://invalid.origin"
         }
+        self.server.client.env.update(env)
+
         headers={"accept": "application/json; version=1",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": env['CONTENT_LENGTH'],
@@ -1760,6 +1779,9 @@
                             "/rest/data/user/%s/realname"%self.joeid,
                             form)
 
+        # invalid origin, no credentials allowed.
+        self.assertNotIn("Access-Control-Allow-Credentials",
+                         self.server.client.additional_headers)
         self.assertEqual(self.server.client.response_code, 200)
         results = self.server.get_element('user', self.joeid, self.empty_form)
         self.assertEqual(self.dummy_client.response_code, 200)
@@ -1841,6 +1863,7 @@
                             "/rest/data/user/%s/realname"%self.joeid,
                             form)
         self.assertEqual(self.dummy_client.response_code, 200)
+        self.server.client.env.update({'REQUEST_METHOD': "GET"})
         results = self.server.dispatch('GET',
                             "/rest/data/user/%s/realname"%self.joeid,
                                        self.empty_form)
@@ -1872,6 +1895,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "PATCH"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(body)
@@ -1925,6 +1949,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(body)
@@ -1958,6 +1983,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(body)
@@ -1989,6 +2015,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json; version=1",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(body)
@@ -2003,7 +2030,7 @@
         results = self.server.dispatch('POST',
                             "/rest/data/status",
                             form)
-
+        self.server.client.env.update(env)
         self.assertEqual(self.server.client.response_code, 400)
         json_dict = json.loads(b2s(results))
         status=json_dict['error']['status']
@@ -2021,6 +2048,7 @@
         env = {"CONTENT_TYPE": "application/json",
                "CONTENT_LEN": 0,
                "REQUEST_METHOD": "DELETE" }
+        self.server.client.env.update(env)
         # use text/plain header and request json output by appending
         # .json to the url.
         headers={"accept": "text/plain",
@@ -2044,6 +2072,7 @@
         status=json_dict['data']['status']
         self.assertEqual(status, 'ok')
 
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
         results = self.server.dispatch('GET',
                             "/rest/data/issuetitle:=asdf.jon",
                             form)
@@ -2071,6 +2100,9 @@
         form.list = [
             cgi.MiniFieldStorage('@apiver', 'L'),
         ]
+
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
+
         headers={"accept": "application/json; notversion=z" }
         self.headers=headers
         self.server.client.request.headers.get=self.get_header
@@ -2228,6 +2260,8 @@
         del(self.headers)
 
     def testAcceptHeaderParsing(self):
+        self.server.client.env['REQUEST_METHOD'] = 'GET'
+
         # TEST #1
         # json highest priority
         self.server.client.request.headers.get=self.get_header
@@ -2377,6 +2411,9 @@
                                          headers=headers,
                                          environ=env)
             self.db.setCurrentUser('admin') # must be admin to create status
+
+            self.server.client.env.update({'REQUEST_METHOD': method})
+
             results = self.server.dispatch(method,
                                            "/rest/data/status",
                                            form)
@@ -2407,6 +2444,7 @@
                                 environ=env)
         self.server.client.request.headers.get=self.get_header
         self.db.setCurrentUser('admin') # must be admin to delete issue
+        self.server.client.env.update({'REQUEST_METHOD': 'POST'})
         results = self.server.dispatch('POST',
                             "/rest/data/status/1",
                             form)
@@ -2430,6 +2468,8 @@
                 "CONTENT_LENGTH": len(empty_body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
+
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(empty_body)
@@ -2460,6 +2500,7 @@
                 "CONTENT_LENGTH": len(body),
                 "REQUEST_METHOD": "POST"
         }
+        self.server.client.env.update(env)
         headers={"accept": "application/json",
                  "content-type": env['CONTENT_TYPE'],
                  "content-length": len(body)
@@ -2499,6 +2540,7 @@
 
         ## Try using GET on POE url. Should fail with method not
         ## allowed (405)
+        self.server.client.env.update({'REQUEST_METHOD': 'GET'})
         self.server.client.request.headers.get=self.get_header
         results = self.server.dispatch('GET',
                             "/rest/data/issue/@poe",
@@ -2513,6 +2555,7 @@
                                 headers=headers,
                                 environ=env)
         self.server.client.request.headers.get=self.get_header
+        self.server.client.env.update({'REQUEST_METHOD': 'POST'})
         results = self.server.dispatch('POST',
                             "/rest/data/issue/@poe",
                             form)
@@ -3425,6 +3468,68 @@
         self.assertEqual(len(results['attributes']['nosy']), 0)
         self.assertListEqual(results['attributes']['nosy'], [])
 
+
+    def testRestMatchWildcardOrigin(self):
+        # cribbed from testDispatch #1
+        # PUT: joe's 'realname' using json data.
+        # simulate: /rest/data/user/<id>/realname
+        # use etag in header
+
+        # verify that credential header is missing, valid allow origin
+        # header and vary includes origin.
+
+        local_client = self.server.client
+        etag = calculate_etag(self.db.user.getnode(self.joeid),
+                              self.db.config['WEB_SECRET_KEY'])
+        body = b'{ "data": "Joe Doe 1" }'
+        env = { "CONTENT_TYPE": "application/json",
+                "CONTENT_LENGTH": len(body),
+                "REQUEST_METHOD": "PUT",
+                "HTTP_ORIGIN": "https://bad.origin"
+        }
+        local_client.env.update(env)
+
+        local_client.db.config["WEB_ALLOWED_API_ORIGINS"] = " * "
+
+        headers={"accept": "application/json; version=1",
+                 "content-type": env['CONTENT_TYPE'],
+                 "content-length": env['CONTENT_LENGTH'],
+                 "if-match": etag,
+                 "origin": env['HTTP_ORIGIN']
+        }
+        self.headers=headers
+        # we need to generate a FieldStorage the looks like
+        #  FieldStorage(None, None, 'string') rather than
+        #  FieldStorage(None, None, [])
+        body_file=BytesIO(body)  # FieldStorage needs a file
+        form = client.BinaryFieldStorage(body_file,
+                                headers=headers,
+                                environ=env)
+        local_client.request.headers.get=self.get_header
+        results = self.server.dispatch('PUT',
+                            "/rest/data/user/%s/realname"%self.joeid,
+                            form)
+
+        self.assertNotIn("Access-Control-Allow-Credentials",
+                         local_client.additional_headers)
+
+        self.assertIn("Access-Control-Allow-Origin",
+                      local_client.additional_headers)
+        self.assertEqual(
+            headers['origin'], 
+            local_client.additional_headers["Access-Control-Allow-Origin"])
+
+
+        self.assertIn("Vary", local_client.additional_headers)
+        self.assertIn("Origin",
+                      local_client.additional_headers['Vary'])
+
+        self.assertEqual(local_client.response_code, 200)
+        results = self.server.get_element('user', self.joeid, self.empty_form)
+        self.assertEqual(self.dummy_client.response_code, 200)
+        self.assertEqual(results['data']['attributes']['realname'],
+                         'Joe Doe 1')
+
     @skip_jwt
     def test_expired_jwt(self):
         # self.dummy_client.main() closes database, so

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