Mercurial > p > roundup > code
changeset 5726:e199d0ae4a25
issue2551033: prevent reverse engineering hidden data by using etags
as an oracle to identify when the right data has been
guessed. Identified by Joseph Myers who also suggested remediation
methods. Implemented John Rouillard.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Thu, 23 May 2019 18:56:57 -0400 |
| parents | 6923225fd781 |
| children | 8b5171f353eb |
| files | CHANGES.txt doc/upgrading.txt roundup/configuration.py roundup/rest.py test/rest_common.py |
| diffstat | 5 files changed, 84 insertions(+), 38 deletions(-) [+] |
line wrap: on
line diff
--- a/CHANGES.txt Wed May 22 20:56:59 2019 -0400 +++ b/CHANGES.txt Thu May 23 18:56:57 2019 -0400 @@ -128,6 +128,9 @@ template. Replace with frame macro. (Cédric Krier) - handle UnicodeDecodeError in file class when file contents are not text (e.g. jpg). (John Rouillard) +- issue2551033: prevent reverse engineering hidden data by using etags + as an oracle to identify when the right data has been + guessed. (Joseph Myers, John Rouillard) 2018-07-13 1.6.0
--- a/doc/upgrading.txt Wed May 22 20:56:59 2019 -0400 +++ b/doc/upgrading.txt Thu May 23 18:56:57 2019 -0400 @@ -23,6 +23,18 @@ Migrating from 1.6.0 to x.y.0 ============================= +Upgrade tracker's config.ini file +-------------------------------------- +Once you have installed the new roundup, use: + + roundup-admin -i /path/to/tracker updateconfig new_init_file.ini + +to generate a new ini file preserving all your settings. You can then +merge any local comments from the tracker's ``config.ini`` into +``new_init_file.ini``. Compare the old and new files and configure set +any new settings as you want. Then replace ``config.ini`` with the new +init file. + PGP mail processing -------------------
--- a/roundup/configuration.py Wed May 22 20:56:59 2019 -0400 +++ b/roundup/configuration.py Thu May 23 18:56:57 2019 -0400 @@ -23,6 +23,10 @@ import roundup.date +from roundup.anypy.strings import b2s +import roundup.anypy.random_ as random_ +import binascii + # XXX i don't think this module needs string translation, does it? ### Exceptions @@ -95,6 +99,9 @@ NODEFAULT = UnsetDefaultValue() +def create_token(): + return b2s(binascii.b2a_base64(random_.token_bytes(32)).strip()) + ### Option classes class Option: @@ -467,6 +474,14 @@ def _value2str(self, value): return oct(value) +class MandatoryOption(Option): + """Option must not be empty""" + def str2value(self, value): + if not value: + raise OptionValueError(self,value,"Value must not be empty.") + else: + return value + class NullableOption(Option): """Option that is set to None if its string value is one of NULL strings @@ -851,6 +866,18 @@ "Setting this option makes Roundup migrate passwords with\n" "an insecure password-scheme to a more secure scheme\n" "when the user logs in via the web-interface."), + (MandatoryOption, "secret_key", create_token(), + "A per tracker secret used in etag calculations for\n" + "an object. It must not be empty.\n" + "It prevents reverse engineering hidden data in an object\n" + "by calculating the etag for a sample object. Then modifying\n" + "hidden properties until the sample object's etag matches\n" + "the one returned by roundup.\n" + "Changing this changes the etag and invalidates updates by\n" + "clients. It must be persistent across application restarts.\n" + "(Note the default value changes every time\n" + " roundup-admin updateconfig\n" + "is run, so it must be explicitly set to a non-empty string.\n"), )), ("rdbms", ( (Option, 'name', 'roundup',
--- a/roundup/rest.py Wed May 22 20:56:59 2019 -0400 +++ b/roundup/rest.py Thu May 23 18:56:57 2019 -0400 @@ -37,7 +37,7 @@ from roundup.exceptions import * from roundup.cgi.exceptions import * -from hashlib import md5 +import hmac # Py3 compatible basestring try: @@ -118,7 +118,7 @@ return result return format_object -def calculate_etag (node, classname="Missing", id="0"): +def calculate_etag (node, key, classname="Missing", id="0"): '''given a hyperdb node generate a hashed representation of it to be used as an etag. @@ -142,13 +142,13 @@ ''' items = node.items(protected=True) # include every item - etag = md5(bs2b(repr(sorted(items)))).hexdigest() + etag = hmac.new(bs2b(repr(sorted(items)))).hexdigest() logger.debug("object=%s%s; tag=%s; repr=%s", classname, id, etag, repr(node.items(protected=True))) # Quotes are part of ETag spec, normal headers don't have quotes return '"%s"' % etag -def check_etag (node, etags, classname="Missing", id="0"): +def check_etag (node, key, etags, classname="Missing", id="0"): '''Take a list of etags and compare to the etag for the given node. Iterate over all supplied etags, @@ -159,7 +159,7 @@ ''' have_etag_match=False - node_etag = calculate_etag(node, classname, id) + node_etag = calculate_etag(node, key, classname, id) for etag in etags: if etag is not None: @@ -511,6 +511,7 @@ def raise_if_no_etag(self, class_name, item_id, input): class_obj = self.db.getclass(class_name) if not check_etag(class_obj.getnode(item_id), + self.db.config.WEB_SECRET_KEY, obtain_etags(self.client.request.headers, input), class_name, item_id): @@ -787,7 +788,8 @@ ) node = class_obj.getnode(itemid) - etag = calculate_etag(node, class_name, itemid) + etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY, + class_name, itemid) props = None protected=False verbose=1 @@ -868,7 +870,8 @@ class_obj = self.db.getclass(class_name) node = class_obj.getnode(item_id) - etag = calculate_etag(node, class_name, item_id) + etag = calculate_etag(node, self.db.config.WEB_SECRET_KEY, + class_name, item_id) data = node.__getattr__(attr_name) result = { 'id': item_id,
--- a/test/rest_common.py Wed May 22 20:56:59 2019 -0400 +++ b/test/rest_common.py Thu May 23 18:56:57 2019 -0400 @@ -635,13 +635,13 @@ ) node = self.db.user.getnode(self.joeid) - etag = calculate_etag(node) + etag = calculate_etag(node, "zysjskakss") items = node.items(protected=True) # include every item print(repr(items)) print(etag) self.assertEqual(etag, "6adf97f83acf6453d4a6a4b1070f3754") - etag = calculate_etag(self.db.issue.getnode("1")) + etag = calculate_etag(self.db.issue.getnode("1"), "zysjskakss") print(etag) self.assertEqual(etag, "6adf97f83acf6453d4a6a4b1070f3754") @@ -667,7 +667,8 @@ pass form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), + "zysjskakss") form.list = [ cgi.MiniFieldStorage('data', 'Joe Doe Doe'), ] @@ -775,7 +776,7 @@ # PUT: joe's 'realname' using json data. # simulate: /rest/data/user/<id>/realname # use etag in header - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") body=b'{ "data": "Joe Doe 1" }' env = { "CONTENT_TYPE": "application/json", "CONTENT_LENGTH": len(body), @@ -824,7 +825,7 @@ # Set joe's 'realname' using json data. # simulate: /rest/data/user/<id>/realname # use etag in payload - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") etagb = etag.strip ('"') body=s2b('{ "@etag": "\\"%s\\"", "data": "Joe Doe 2" }'%etagb) env = { "CONTENT_TYPE": "application/json", @@ -863,7 +864,7 @@ # # Also use GET on the uri via the dispatch to retrieve # the results from the db. - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") headers={"if-match": etag, "accept": "application/vnd.json.test-v1+json", } @@ -901,7 +902,7 @@ self.empty_form) self.assertEqual(self.dummy_client.response_code, 200) - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") etagb = etag.strip ('"') body=s2b('{ "address": "demo2@example.com", "@etag": "\\"%s\\""}'%etagb) env = { "CONTENT_TYPE": "application/json", @@ -929,7 +930,7 @@ 'demo2@example.com') # and set it back reusing env and headers from last test - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") etagb = etag.strip ('"') body=s2b('{ "address": "%s", "@etag": "\\"%s\\""}'%( stored_results['data']['attributes']['address'], @@ -1049,7 +1050,7 @@ # TEST #8 # DELETE: delete issue 1 - etag = calculate_etag(self.db.issue.getnode("1")) + etag = calculate_etag(self.db.issue.getnode("1"), "zysjskakss") etagb = etag.strip ('"') env = {"CONTENT_TYPE": "application/json", "CONTENT_LEN": 0, @@ -1350,7 +1351,7 @@ # change Joe's realname via attribute uri - etag in header form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") form.list = [ cgi.MiniFieldStorage('data', 'Joe Doe Doe'), ] @@ -1373,7 +1374,7 @@ # with all fields, change one field and put the result without # having to filter out protected items. form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") form.list = [ cgi.MiniFieldStorage('creator', '3'), cgi.MiniFieldStorage('realname', 'Joe Doe'), @@ -1394,7 +1395,7 @@ # This should result in no change to the name and # a 400 UsageError stating prop does not exist. form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") form.list = [ cgi.MiniFieldStorage('JustKidding', '3'), cgi.MiniFieldStorage('realname', 'Joe Doe'), @@ -1418,7 +1419,7 @@ # make sure we don't have permission issues self.db.setCurrentUser('admin') form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") form.list = [ cgi.MiniFieldStorage('data', '3'), cgi.MiniFieldStorage('@etag', etag) @@ -1440,7 +1441,7 @@ # make sure we don't have permission issues self.db.setCurrentUser('admin') form = cgi.FieldStorage() - etag = calculate_etag(self.db.user.getnode(self.joeid)) + etag = calculate_etag(self.db.user.getnode(self.joeid), "zysjskakss") form.list = [ cgi.MiniFieldStorage('data', '3'), cgi.MiniFieldStorage('@etag', etag) @@ -1582,7 +1583,7 @@ [{'id': '1', 'link': self.url_pfx + 'user/1'}]) form = cgi.FieldStorage() - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list.append(cgi.MiniFieldStorage('@etag', etag)) # remove the title and nosy results = self.server.delete_attribute( @@ -1591,7 +1592,7 @@ self.assertEqual(self.dummy_client.response_code, 200) del(form.list[-1]) - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list.append(cgi.MiniFieldStorage('@etag', etag)) results = self.server.delete_attribute( 'issue', issue_id, 'nosy', form @@ -1607,7 +1608,7 @@ self.assertEqual(results['attributes']['title'], None) # delete protected property - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list.append(cgi.MiniFieldStorage('@etag', etag)) results = self.server.delete_attribute( 'issue', issue_id, 'creator', form @@ -1624,7 +1625,7 @@ self.assertEqual(self.dummy_client.response_code, 405) # delete required property - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list.append(cgi.MiniFieldStorage('@etag', etag)) results = self.server.delete_attribute( 'issue', issue_id, 'requireme', form @@ -1642,7 +1643,7 @@ self.assertEqual(self.dummy_client.response_code, 400) # delete bogus property - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list.append(cgi.MiniFieldStorage('@etag', etag)) results = self.server.delete_attribute( 'issue', issue_id, 'nosuchprop', form @@ -1676,7 +1677,7 @@ results = self.server.patch_element('issue', issue_id, form) self.assertEqual(self.dummy_client.response_code, 412) - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'add'), @@ -1693,7 +1694,7 @@ self.assertEqual(len(results['attributes']['nosy']), 2) self.assertListEqual(results['attributes']['nosy'], ['1', '2']) - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'add'), @@ -1712,7 +1713,7 @@ # patch invalid property - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'add'), @@ -1758,7 +1759,7 @@ self.assertListEqual(results['attributes']['nosy'], ['1']) # replace userid 2 to the nosy list and status = 3 - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'replace'), @@ -1777,7 +1778,7 @@ self.assertListEqual(results['attributes']['nosy'], ['2']) # replace status = 2 using status attribute - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'replace'), @@ -1794,7 +1795,7 @@ self.assertEqual(results['attributes']['status'], '2') # try to set a protected prop. It should fail. - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'replace'), @@ -1815,7 +1816,7 @@ # try to set a protected prop using patch_attribute. It should # fail with a 405 bad/unsupported method. - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'replace'), @@ -1861,7 +1862,7 @@ # remove the nosy list and the title form = cgi.FieldStorage() - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list = [ cgi.MiniFieldStorage('@op', 'remove'), cgi.MiniFieldStorage('nosy', ''), @@ -1880,7 +1881,7 @@ self.assertEqual(results['attributes']['nosy'], []) # try to remove a protected prop. It should fail. - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form = cgi.FieldStorage() form.list = [ cgi.MiniFieldStorage('@op', 'remove'), @@ -1900,7 +1901,7 @@ self.assertEqual(self.dummy_client.response_code, 400) # try to remove a required prop. it should fail - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list = [ cgi.MiniFieldStorage('@op', 'remove'), cgi.MiniFieldStorage('requireme', ''), @@ -1939,7 +1940,7 @@ # execute action retire form = cgi.FieldStorage() - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list = [ cgi.MiniFieldStorage('@op', 'action'), cgi.MiniFieldStorage('@action_name', 'retire'), @@ -1975,7 +1976,7 @@ # remove the nosy list and the title form = cgi.FieldStorage() - etag = calculate_etag(self.db.issue.getnode(issue_id)) + etag = calculate_etag(self.db.issue.getnode(issue_id), "zysjskakss") form.list = [ cgi.MiniFieldStorage('@op', 'remove'), cgi.MiniFieldStorage('nosy', '1, 2'),
