changeset 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 6f09103a6522
files CHANGES.txt doc/rest.txt doc/upgrading.txt roundup/cgi/client.py roundup/configuration.py roundup/rest.py test/rest_common.py test/test_cgi.py test/test_config.py
diffstat 9 files changed, 283 insertions(+), 43 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Tue Feb 21 23:06:15 2023 -0500
+++ b/CHANGES.txt	Thu Feb 23 12:01:33 2023 -0500
@@ -65,6 +65,9 @@
   Schlatterbeck)
 - Update some template schema files to assign Register permissions for the
   Anonymous user. Replaces the old Create permission. (John Rouillard)
+- Allow '*' and explicit origins in allowed_api_origins. Only return 
+  'Access-Control-Allow-Credentials' when not matching '*'. Fixes
+  security issue with rest when using '*'.
 
 Features:
 
--- a/doc/rest.txt	Tue Feb 21 23:06:15 2023 -0500
+++ b/doc/rest.txt	Thu Feb 23 12:01:33 2023 -0500
@@ -231,14 +231,26 @@
 * `Access-Control-Request-Method`
 * `Origin`
 
-The 204 response will include the headers:
+The headers of the 204 response depend on the
+``allowed_api_origins`` setting. If a ``*`` is included as the
+first element, any client can read the data but they can not
+provide authentication. This limits the available data to what
+the anonymous user can see in the web interface.
+
+All 204 responses will include the headers:
 
 * `Access-Control-Allow-Origin`
 * `Access-Control-Allow-Headers`
 * `Access-Control-Allow-Methods`
-* `Access-Control-Allow-Credentials: true`
 * `Access-Control-Max-Age: 86400`
 
+If the client's ORIGIN header matches an entry besides ``*`` in the
+``allowed_api_origins`` it will also include:
+
+* `Access-Control-Allow-Credentials: true`
+
+permitting the client to log in and perform authenticated operations.
+  
 If the endpoint accepts the PATCH verb the header `Accept-Patch` with
 valid mime types (usually `application/x-www-form-urlencoded,
 multipart/form-data`) will be included.
--- a/doc/upgrading.txt	Tue Feb 21 23:06:15 2023 -0500
+++ b/doc/upgrading.txt	Thu Feb 23 12:01:33 2023 -0500
@@ -236,6 +236,25 @@
 For details on WAL mode see `<https://www.sqlite.org/wal.html>`_
 and `<https://www.sqlite.org/pragma.html#pragma_journal_mode>`_.
 
+Change in processing allowed_api_origins setting
+------------------------------------------------
+
+In this release you can use both ``*`` (as the first origin) and
+explicit origins in the `allowed_api_origins`` setting in
+``config.ini``. (Before it was only one or the other.)
+
+You do not need to use ``*``. If you do, it allows any client
+anonymous (unauthenticated) access to the Roundup tracker. This
+is the same as browsing the tracker without logging in. If they
+try to provide credentials, access to the data will be denied by
+`CORS`_.
+
+If you include explicit origins (e.g. \https://example.com),
+users from those origins will not be blocked if they use
+credentials to log in.
+
+.. _CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
+
 Change in processing of In-Reply_to email header
 ------------------------------------------------
 
--- a/roundup/cgi/client.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/roundup/cgi/client.py	Thu Feb 23 12:01:33 2023 -0500
@@ -1279,15 +1279,20 @@
                 raise Unauthorised(self._("Anonymous users are not "
                                           "allowed to use the web interface"))
 
-    def is_origin_header_ok(self, api=False):
+    def is_origin_header_ok(self, api=False, credentials=False):
         """Determine if origin is valid for the context
 
-           Allow (return True) if ORIGIN is missing and it is a GET.
-           Allow if ORIGIN matches the base url.
+           Header is ok (return True) if ORIGIN is missing and it is a GET.
+           Header is ok if ORIGIN matches the base url.
            If this is a API call:
-             Allow if ORIGIN matches an element of allowed_api_origins.
-             Allow if allowed_api_origins includes '*' as first element..
-           Otherwise disallow.
+             Header is ok if ORIGIN matches an element of allowed_api_origins.
+             Header is ok if allowed_api_origins includes '*' as first
+               element and credentials is False.
+           Otherwise header is not ok.
+
+           In a credentials context, if we match * we will return
+           header is not ok. All credentialed requests must be
+           explicitly matched.
         """
 
         try:
@@ -1312,9 +1317,15 @@
         # Original spec says origin is case sensitive match.
         # Living spec doesn't address Origin value's case or
         # how to compare it. So implement case sensitive....
-        if allowed_origins:
-            if allowed_origins[0] == '*' or origin in allowed_origins:
-                return True
+        if origin in allowed_origins:
+             return True
+        # Block use of * when origin match is used for
+        # allowing credentials. See:
+        # https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
+        # under Credentials Requests and Wildcards
+        if ( allowed_origins and allowed_origins[0] == '*'
+             and not credentials):
+            return True
 
         return False
 
--- a/roundup/configuration.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/roundup/configuration.py	Thu Feb 23 12:01:33 2023 -0500
@@ -571,9 +571,10 @@
         pathlist = self._value = []
         for elem in _val.split():
             pathlist.append(elem)
-        if '*' in pathlist and len(pathlist) != 1:
-            raise OptionValueError(self, _val,
-                                   "If using '*' it must be the only element.")
+        if '*' in pathlist and pathlist[0] != '*':
+            raise OptionValueError(
+                self, _val,
+                "If using '*' it must be the first element.")
 
     def _value2str(self, value):
         return ','.join(value)
@@ -1317,13 +1318,13 @@
 'https://Bar.edu' are two different Origin values. Note that
 the origin value is scheme://host. There is no path
 component. So 'https://bar.edu/' would never be valid.
-Also the value * can be used to match any origin. Note that
-this value allows any web page on the internet to make
-authenticated requests against your Roundup tracker and
-is not a good idea.
+The value '*' can be used to match any origin. It must be
+first in the list if used. Note that this value allows
+any web page on the internet to make anonymous requests
+against your Roundup tracker.
 
 You need to set these if you have a web application on a
-different origin accessing your roundup instance.
+different origin accessing your Roundup instance.
 
 (The origin from the tracker.web setting in config.ini is
 always valid and does not need to be specified.)"""),
--- a/roundup/rest.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/roundup/rest.py	Thu Feb 23 12:01:33 2023 -0500
@@ -2201,11 +2201,24 @@
             self.client.request.headers.get("Origin")
         )
 
-        # allow credentials
-        self.client.setHeader(
-            "Access-Control-Allow-Credentials",
-            "true"
-        )
+        # Allow credentials if origin is acceptable.
+        #
+        # If Access-Control-Allow-Credentials header not returned,
+        # but the client request is made with credentials
+        # data will be sent but not made available to the
+        # calling javascript in browser.
+        # Prevents exposure of data to an invalid origin when
+        # credentials are sent by client.
+        #
+        # If admin puts * first in allowed_api_origins
+        # we do not allow credentials but do reflect the origin.
+        # This allows anonymous access.
+        if self.client.is_origin_header_ok(api=True, credentials=True):
+            self.client.setHeader(
+                "Access-Control-Allow-Credentials",
+                "true"
+            )
+
         # set allow header in case of error. 405 handlers below should
         # replace it with a custom version as will OPTIONS handler
         # doing CORS.
--- 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
--- 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",
--- a/test/test_config.py	Tue Feb 21 23:06:15 2023 -0500
+++ b/test/test_config.py	Thu Feb 23 12:01:33 2023 -0500
@@ -305,12 +305,13 @@
         config = configuration.CoreConfig()
 
         with self.assertRaises(configuration.OptionValueError) as cm:
-            config._get_option('WEB_ALLOWED_API_ORIGINS').set("* https://foo.edu")
+            config._get_option('WEB_ALLOWED_API_ORIGINS').set("https://foo.edu *")
+
+        config._get_option('WEB_ALLOWED_API_ORIGINS').set("* https://foo.edu HTTP://baR.edu")
 
-        config._get_option('WEB_ALLOWED_API_ORIGINS').set("https://foo.edu HTTP://baR.edu")
-
-        self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][0], 'https://foo.edu')
-        self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][1], 'HTTP://baR.edu')
+        self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][0], '*')
+        self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][1], 'https://foo.edu')
+        self.assertEqual(config['WEB_ALLOWED_API_ORIGINS'][2], 'HTTP://baR.edu')
         
 
 

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