diff -r 994893cf3e1a roundup/cgi/client.py --- a/roundup/cgi/client.py Mon May 30 18:03:39 2022 +0200 +++ b/roundup/cgi/client.py Wed Jun 01 10:34:26 2022 -0400 @@ -620,7 +620,32 @@ self.write(output) return - if not self.db.security.hasPermission('Rest Access', self.userid): + # allow preflight request even if unauthenticated + if ( self.env['REQUEST_METHOD'] == "OPTIONS" + and self.request.headers.get ("Access-Control-Request-Headers") + and self.request.headers.get ("Access-Control-Request-Method") + and self.request.headers.get ("Origin") + ): + # verify Origin is allowed + if not self.is_origin_header_ok(api=True): + # Use code 400 as 401 and 403 imply that authentication + # is needed or authenticates person is not authorized. + # Preflight doesn't do authentication. + self.response_code = 400 + msg = self._("Client is not allowed to use Rest Interface.") + output = s2b( + '{ "error": { "status": 400, "msg": "%s" } }'%msg ) + self.setHeader("Content-Length", str(len(output))) + self.setHeader("Content-Type", "application/json") + self.write(output) + return + + # Call rest library to handle the pre-flight request + handler = rest.RestfulInstance(self, self.db) + output = handler.dispatch(self.env['REQUEST_METHOD'], + self.path, self.form) + + elif not self.db.security.hasPermission('Rest Access', self.userid): self.response_code = 403 output = s2b('{ "error": { "status": 403, "msg": "Forbidden." } }') self.setHeader("Content-Length", str(len(output))) @@ -636,14 +661,12 @@ # raises exception on check failure. csrf_ok = self.handle_csrf(api=True) except (Unauthorised, UsageError) as msg: - # report exception back to server - exc_type, exc_value, exc_tb = sys.exc_info() # FIXME should return what the client requests # via accept header. - output = s2b("%s: %s\n"%(exc_type, exc_value)) + output = s2b('{ "error": { "status": 400, "msg": "%s"}}'% str(msg)) self.response_code = 400 self.setHeader("Content-Length", str(len(output))) - self.setHeader("Content-Type", "text/plain") + self.setHeader("Content-Type", "application/json") self.write(output) csrf_ok = False # we had an error, failed check return @@ -1223,9 +1246,39 @@ # 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[0] == '*' or origin in allowed_origins: + if allowed_origins: + if allowed_origins[0] == '*' or origin in allowed_origins: + return True + + return False + + def is_referer_header_ok(self, api=False): + referer = self.env['HTTP_REFERER'] + # parse referer and create an origin + referer_comp = urllib_.urlparse(referer) + + # self.base always has trailing /, so add trailing / to referer_origin + referer_origin = "%s://%s/"%(referer_comp[0], referer_comp[1]) + foundat = self.base.find(referer_origin) + if foundat == 0: return True + if not api: + return False + + allowed_origins = self.db.config['WEB_ALLOWED_API_ORIGINS'] + if allowed_origins[0] == '*': + return True + + # For referer, loop over allowed_api_origins and + # see if any of them are a prefix to referer, case sensitive. + # Append / to each origin so that: + # an allowed_origin of https://my.host does not match + # a referer of https://my.host.com/my/path + for allowed_origin in allowed_origins: + foundat = referer_origin.find(allowed_origin + '/') + if foundat == 0: + return True return False def handle_csrf(self, api=False): @@ -1335,13 +1388,11 @@ # self.base always matches: ^https?://hostname enforce=config['WEB_CSRF_ENFORCE_HEADER_REFERER'] if 'HTTP_REFERER' in self.env and enforce != "no": - referer = self.env['HTTP_REFERER'] - # self.base always has trailing / - foundat = referer.find(self.base) - if foundat != 0: + if not self.is_referer_header_ok(api=api): + referer = self.env['HTTP_REFERER'] if enforce in ('required', 'yes'): logger.error(self._("csrf Referer header check failed for user%s. Value=%s"), current_user, referer) - raise Unauthorised(self._("Invalid Referer %s, %s")%(referer,self.base)) + raise Unauthorised(self._("Invalid Referer: %s")%(referer)) elif enforce == 'logfailure': logger.warning(self._("csrf Referer header check failed for user%s. Value=%s"), current_user, referer) else: diff -r 994893cf3e1a roundup/rest.py --- a/roundup/rest.py Mon May 30 18:03:39 2022 +0200 +++ b/roundup/rest.py Wed Jun 01 10:34:26 2022 -0400 @@ -1751,6 +1751,10 @@ "Allow", "OPTIONS, GET, PUT, DELETE, PATCH" ) + self.client.setHeader( + "Access-Control-Allow-Methods", + "OPTIONS, GET, PUT, DELETE, PATCH" + ) return 204, "" @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'OPTIONS') @@ -1774,12 +1778,20 @@ "Allow", "OPTIONS, GET, PUT, DELETE, PATCH" ) + self.client.setHeader( + "Access-Control-Allow-Methods", + "OPTIONS, GET, PUT, DELETE, PATCH" + ) elif attr_name in class_obj.getprops(protected=True): # It must match a protected prop. These can't be written. self.client.setHeader( "Allow", "OPTIONS, GET" ) + self.client.setHeader( + "Access-Control-Allow-Methods", + "OPTIONS, GET" + ) else: raise NotFound('Attribute %s not valid for Class %s' % ( attr_name, class_name)) @@ -1910,6 +1922,10 @@ "Allow", "OPTIONS, GET" ) + self.client.setHeader( + "Access-Control-Allow-Methods", + "OPTIONS, GET" + ) return 204, "" @Routing.route("/data") @@ -1939,6 +1955,10 @@ "Allow", "OPTIONS, GET" ) + self.client.setHeader( + "Access-Control-Allow-Methods", + "OPTIONS, GET" + ) return 204, "" @Routing.route("/summary") @@ -2161,20 +2181,46 @@ # with invalid values. data_type = ext_type or accept_type or headers.get('Accept') or "invalid" - # add access-control-allow-* to support CORS - self.client.setHeader("Access-Control-Allow-Origin", "*") + if method.upper() == 'OPTIONS': + # add access-control-allow-* access-control-max-age to support + # CORS preflight + self.client.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override" + ) + # can be overridden by options handlers to provide supported + # methods for endpoint + self.client.setHeader( + "Access-Control-Allow-Methods", + "HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH" + ) + # cache the Access headings for a week. Allows one CORS pre-flight + # request to be reused again and again. + self.client.setHeader("Access-Control-Max-Age", "86400") + + # response may change based on Origin value. + self.client.setVary("Origin") + + # Allow-Origin must match origin supplied by client. '*' doesn't + # work for authenticated requests. self.client.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override" + "Access-Control-Allow-Origin", + self.client.request.headers.get("Origin") ) + + # allow credentials + 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. self.client.setHeader( "Allow", "OPTIONS, GET, POST, PUT, DELETE, PATCH" ) - self.client.setHeader( - "Access-Control-Allow-Methods", - "HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH" - ) + # Is there an input.value with format json data? # If so turn it into an object that emulates enough # of the FieldStorge methods/props to allow a response. diff -r 994893cf3e1a test/test_cgi.py --- a/test/test_cgi.py Mon May 30 18:03:39 2022 +0200 +++ b/test/test_cgi.py Wed Jun 01 10:34:26 2022 -0400 @@ -1169,6 +1169,20 @@ del(cl.env['HTTP_ORIGIN']) cl.db.config.WEB_ALLOWED_API_ORIGINS = "" del(out[0]) + + # test by setting allowed api origins to * + # this should not redirect as it is not an API call. + cl.db.config.WEB_ALLOWED_API_ORIGINS = " * " + cl.env['HTTP_ORIGIN'] = 'http://whoami.com' + cl.env['HTTP_REFERER'] = 'https://baz.edu/path/' + cl.inner_main() + match_at=out[0].find('Invalid Referer: https://baz.edu/path/') + print("result of subtest invalid referer:", out[0]) + self.assertEqual(match_at, 36) + del(cl.env['HTTP_ORIGIN']) + del(cl.env['HTTP_REFERER']) + cl.db.config.WEB_ALLOWED_API_ORIGINS = "" + del(out[0]) # clean up from email log if os.path.exists(SENDMAILDEBUG): @@ -1215,7 +1229,7 @@ # Should return explanation because content type is text/plain # and not text/xml cl.handle_rest() - self.assertEqual(b2s(out[0]), ": Required Header Missing\n") + self.assertEqual(b2s(out[0]), '{ "error": { "status": 400, "msg": "Required Header Missing"}}') del(out[0]) cl = client.Client(self.instance, None, @@ -1320,7 +1334,7 @@ # Should return explanation because content type is text/plain # and not text/xml cl.handle_rest() - self.assertEqual(b2s(out[0]), ": Invalid Origin httxs://bar.edu\n") + self.assertEqual(b2s(out[0]), '{ "error": { "status": 400, "msg": "Invalid Origin httxs://bar.edu"}}') del(out[0]) @@ -1332,7 +1346,7 @@ 'HTTP_ORIGIN': 'httxs://bar.edu', 'HTTP_X_REQUESTED_WITH': 'rest', 'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=', - 'HTTP_REFERER': 'http://whoami.com/path/', + 'HTTP_REFERER': 'httxp://bar.edu/path/', 'HTTP_ACCEPT': "application/json;version=1" }, form) cl.db = self.db @@ -1346,11 +1360,68 @@ cl.write = wh # capture output - # create third issue + # create fourth issue cl.handle_rest() self.assertIn('"id": "3"', b2s(out[0])) del(out[0]) + cl.db.config.WEB_ALLOWED_API_ORIGINS = "httxs://bar.foo.edu httxs://bar.edu" + for referer in [ 'httxs://bar.edu/path/foo', + 'httxs://bar.edu/path/foo?g=zz', + 'httxs://bar.edu']: + cl = client.Client(self.instance, None, + {'REQUEST_METHOD':'POST', + 'PATH_INFO':'rest/data/issue', + 'CONTENT_TYPE': 'application/x-www-form-urlencoded', + 'HTTP_ORIGIN': 'httxs://bar.edu', + 'HTTP_X_REQUESTED_WITH': 'rest', + 'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=', + 'HTTP_REFERER': referer, + 'HTTP_ACCEPT': "application/json;version=1" + }, 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 + + # create fourth issue + cl.handle_rest() + self.assertIn('"id": "', b2s(out[0])) + del(out[0]) + + cl.db.config.WEB_ALLOWED_API_ORIGINS = "httxs://bar.foo.edu httxs://bar.edu" + cl = client.Client(self.instance, None, + {'REQUEST_METHOD':'POST', + 'PATH_INFO':'rest/data/issue', + 'CONTENT_TYPE': 'application/x-www-form-urlencoded', + 'HTTP_ORIGIN': 'httxs://bar.edu', + 'HTTP_X_REQUESTED_WITH': 'rest', + 'HTTP_AUTHORIZATION': 'Basic YWRtaW46YWRtaW4=', + 'HTTP_REFERER': 'httxp://bar.edu/path/', + 'HTTP_ACCEPT': "application/json;version=1" + }, 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 + + # create fourth issue + cl.handle_rest() + self.assertEqual(b2s(out[0]), '{ "error": { "status": 400, "msg": "Invalid Referer: httxp://bar.edu/path/"}}') + del(out[0]) + def testXmlrpcCsrfProtection(self): # set the password for admin so we can log in. passwd=password.Password('admin') diff -r 994893cf3e1a test/test_liveserver.py --- a/test/test_liveserver.py Mon May 30 18:03:39 2022 +0200 +++ b/test/test_liveserver.py Wed Jun 01 10:34:26 2022 -0400 @@ -68,6 +68,10 @@ cls.db.config.MAILHOST = "localhost" cls.db.config.MAIL_HOST = "localhost" cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log" + cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required" + cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com" + + cls.db.config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] = "required" # enable static precompressed files cls.db.config.WEB_USE_PRECOMPRESSED_FILES = 1 @@ -237,21 +241,64 @@ self.assertEqual(f.headers['content-range'], "bytes */%s"%expected_length) + def test_rest_preflight_collection(self): + # no auth for rest csrf preflight + f = requests.options(self.url_base() + '/rest/data/user', + headers = {'content-type': "", + 'x-requested-with': "rest", + 'Access-Control-Request-Headers': + "x-requested-with", + 'Access-Control-Request-Method': "PUT", + 'Origin': "https://client.com"}) + print(f.status_code) + print(f.headers) + print(f.content) + + self.assertEqual(f.status_code, 204) + + expected = { 'Access-Control-Allow-Origin': 'https://client.com', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + 'Allow': 'OPTIONS, GET, POST', + 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', + 'Access-Control-Allow-Credentials': 'true', + } + + # use dict comprehension to filter headers to the ones we want to check + self.assertEqual({ key: value for (key, value) in + f.headers.items() if key in expected }, + expected) + + # use invalid Origin + f = requests.options(self.url_base() + '/rest/data/user', + headers = {'content-type': "application/json", + 'x-requested-with': "rest", + 'Access-Control-Request-Headers': + "x-requested-with", + 'Access-Control-Request-Method': "PUT", + 'Origin': "ZZZ"}) + + self.assertEqual(f.status_code, 400) + + expected = '{ "error": { "status": 400, "msg": "Client is not ' \ + 'allowed to use Rest Interface." } }' + self.assertEqual(b2s(f.content), expected) + + def test_rest_invalid_method_collection(self): # use basic auth for rest endpoint f = requests.put(self.url_base() + '/rest/data/user', auth=('admin', 'sekrit'), headers = {'content-type': "", - 'x-requested-with': "rest"}) + 'X-Requested-With': "rest", + 'Origin': "https://client.com"}) print(f.status_code) print(f.headers) print(f.content) self.assertEqual(f.status_code, 405) - expected = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + expected = { 'Access-Control-Allow-Origin': 'https://client.com', + 'Access-Control-Allow-Credentials': 'true', 'Allow': 'DELETE, GET, OPTIONS, POST', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', } print(f.headers) @@ -277,15 +324,19 @@ # use basic auth for rest endpoint f = requests.options(self.url_base() + '/rest', auth=('admin', 'sekrit'), - headers = {'content-type': ""}) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', + expected = { 'Access-Control-Allow-Origin': 'http://localhost:9001', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Methods': 'OPTIONS, GET', + 'Access-Control-Allow-Credentials': 'true', } # use dict comprehension to remove fields like date, @@ -296,16 +347,18 @@ # use basic auth for rest endpoint f = requests.options(self.url_base() + '/rest/data', auth=('admin', 'sekrit'), - headers = {'content-type': ""} - ) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', + expected = { 'Access-Control-Allow-Origin': 'http://localhost:9001', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Access-Control-Allow-Methods': 'OPTIONS, GET', + 'Access-Control-Allow-Credentials': 'true', } # use dict comprehension to remove fields like date, @@ -316,15 +369,18 @@ # use basic auth for rest endpoint f = requests.options(self.url_base() + '/rest/data/user', auth=('admin', 'sekrit'), - headers = {'content-type': ""}) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', + expected = { 'Access-Control-Allow-Origin': 'http://localhost:9001', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET, POST', 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', + 'Access-Control-Allow-Credentials': 'true', } # use dict comprehension to remove fields like date, @@ -336,15 +392,18 @@ f = requests.options(self.url_base() + '/rest/data/user/1', auth=('admin', 'sekrit'), - headers = {'content-type': ""}) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', + expected = { 'Access-Control-Allow-Origin': 'http://localhost:9001', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET, PUT, DELETE, PATCH', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Access-Control-Allow-Methods': 'OPTIONS, GET, PUT, DELETE, PATCH', + 'Access-Control-Allow-Credentials': 'true', } # use dict comprehension to remove fields like date, @@ -355,15 +414,18 @@ # use basic auth for rest endpoint f = requests.options(self.url_base() + '/rest/data/user/1/username', auth=('admin', 'sekrit'), - headers = {'content-type': ""}) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', + expected = { 'Access-Control-Allow-Origin': 'http://localhost:9001', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET, PUT, DELETE, PATCH', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Access-Control-Allow-Methods': 'OPTIONS, GET, PUT, DELETE, PATCH', + 'Access-Control-Allow-Credentials': 'true', } # use dict comprehension to remove fields like date, @@ -374,13 +436,16 @@ f = requests.options(self.url_base() + '/rest/data/user/1/creator', auth=('admin', 'sekrit'), - headers = {'content-type': ""}) + headers = {'content-type': "", + 'Origin': "http://localhost:9001", + }) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 204) expected1 = dict(expected) expected1['Allow'] = 'OPTIONS, GET' + expected1['Access-Control-Allow-Methods'] = 'OPTIONS, GET' # use dict comprehension to remove fields like date, # content-length etc. from f.headers. @@ -416,6 +481,7 @@ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', 'Allow': 'OPTIONS, GET', 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Access-Control-Allow-Credentials': 'true', } for i in range(10): @@ -585,10 +651,8 @@ self.assertEqual(f.status_code, 200) expected = { 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + 'Access-Control-Allow-Credentials': 'true', 'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH' } content_str = '''{ "data": { @@ -624,24 +688,29 @@ self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected) - def test_compression_gzip(self): + def test_compression_gzip(self, method='gzip'): + if method == 'gzip': + decompressor = None + elif method == 'br': + decompressor = brotli.decompress + elif method == 'zstd': + decompressor = zstd.decompress + # use basic auth for rest endpoint f = requests.get(self.url_base() + '/rest/data/user/1/username', auth=('admin', 'sekrit'), headers = {'content-type': "", - 'Accept-Encoding': 'gzip, foo', + 'Accept-Encoding': '%s, foo'%method, 'Accept': '*/*'}) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 200) expected = { 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + 'Access-Control-Allow-Credentials': 'true', 'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', - 'Content-Encoding': 'gzip', - 'Vary': 'Accept-Encoding', + 'Content-Encoding': method, + 'Vary': 'Origin, Accept-Encoding', } content_str = '''{ "data": { @@ -652,13 +721,23 @@ }''' content = json.loads(content_str) + print(f.content) + print(type(f.content)) - if (type("") == type(f.content)): - json_dict = json.loads(f.content) - else: - json_dict = json.loads(b2s(f.content)) + try: + if (type("") == type(f.content)): + json_dict = json.loads(f.content) + else: + json_dict = json.loads(b2s(f.content)) + except (ValueError, UnicodeDecodeError): + # Handle error from trying to load compressed data as only + # gzip gets decompressed automatically + # ValueError - raised by loads on compressed content python2 + # UnicodeDecodeError - raised by loads on compressed content + # python3 + json_dict = json.loads(b2s(decompressor(f.content))) - # etag wil not match, creation date different + # etag will not match, creation date different del(json_dict['data']['@etag']) # type is "class 'str'" under py3, "type 'str'" py2 @@ -667,35 +746,35 @@ self.assertDictEqual(json_dict, content) - # verify that ETag header ends with -gzip + # verify that ETag header ends with - try: - self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-gzip"$') + self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-%s"$'%method) except AttributeError: # python2 no assertRegex so try substring match - self.assertEqual(33, f.headers['ETag'].rindex('-gzip"')) + self.assertEqual(33, f.headers['ETag'].rindex('-' + method)) # use dict comprehension to remove fields like date, # content-length etc. from f.headers. self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected) - # use basic auth for rest endpoint, error case, bad attribute f = requests.get(self.url_base() + '/rest/data/user/1/foo', auth=('admin', 'sekrit'), headers = {'content-type': "", - 'Accept-Encoding': 'gzip, foo', - 'Accept': '*/*'}) + 'Accept-Encoding': '%s, foo'%method, + 'Accept': '*/*', + 'Origin': 'ZZZZ'}) print(f.status_code) print(f.headers) # NOTE: not compressed payload too small self.assertEqual(f.status_code, 400) expected = { 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Origin': 'ZZZZ', 'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Vary': 'Origin' } content = { "error": @@ -714,159 +793,25 @@ # test file x-fer f = requests.get(self.url_base() + '/@@file/user_utils.js', - headers = { 'Accept-Encoding': 'gzip, foo', + headers = { 'Accept-Encoding': '%s, foo'%method, 'Accept': '*/*'}) print(f.status_code) print(f.headers) self.assertEqual(f.status_code, 200) expected = { 'Content-Type': 'application/javascript', - 'Content-Encoding': 'gzip', - 'Vary': 'Accept-Encoding', - } - - # check first few bytes. - self.assertEqual(b2s(f.content[0:25]), '// User Editing Utilities') - - # use dict comprehension to remove fields like date, - # content-length etc. from f.headers. - self.assertDictEqual({ key: value for (key, value) in - f.headers.items() if key in expected }, - expected) - - # test file x-fer - f = requests.get(self.url_base() + '/user1', - headers = { 'Accept-Encoding': 'gzip, foo', - 'Accept': '*/*'}) - print(f.status_code) - print(f.headers) - - self.assertEqual(f.status_code, 200) - expected = { 'Content-Type': 'text/html; charset=utf-8', - 'Content-Encoding': 'gzip', - 'Vary': 'Accept-Encoding', - } - - # check first few bytes. - self.assertEqual(b2s(f.content[0:25]), '