Mercurial > p > roundup > code
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')
