Mercurial > p > roundup > code
comparison test/test_cgi.py @ 5201:a9ace22e0a2f
issue 2550690 - Adding anti-csrf measures to roundup following
https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
and
https://seclab.stanford.edu/websec/csrf/csrf.pdf
Basically implement Synchronizer (CSRF) Tokens per form on a page.
Single use (destroyed once used). Random input data for the token
includes:
system random implementation in python using /dev/urandom
(fallback to random based on timestamp as the seed. Not
as good, but should be ok for the short lifetime of the
token??)
the id (in cpython it's the memory address) of the object
requesting a token. In theory this depends on memory layout, the
history of the process (how many previous objects have been
allocated from the heap etc.) I claim without any proof that for
long running processes this is another source of randomness. For
short running processes with little activity it could be guessed.
last the floating point time.time() value is added. This may
only have 1 second resolution so may be guessable.
Hopefully for a short lived (2 week by default) token this is
sufficient. Also in the current implementation the user is notified when
validation fails and is told why. This allows the roundup admin to find
the log entry (at error level) and try to resolve the issue. In the
future user notification may change but for now this is probably best.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Sat, 18 Mar 2017 16:59:01 -0400 |
| parents | 349bef975367 |
| children | 9f490cc0effe |
comparison
equal
deleted
inserted
replaced
| 5200:16a8a3f0772c | 5201:a9ace22e0a2f |
|---|---|
| 119 # compile the labels re | 119 # compile the labels re |
| 120 classes = '|'.join(self.db.classes.keys()) | 120 classes = '|'.join(self.db.classes.keys()) |
| 121 self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes, | 121 self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes, |
| 122 re.VERBOSE) | 122 re.VERBOSE) |
| 123 | 123 |
| 124 def setupClient(self, form, classname, nodeid=None, template='item'): | 124 def setupClient(self, form, classname, nodeid=None, template='item', env_addon=None): |
| 125 cl = client.Client(self.instance, None, {'PATH_INFO':'/', | 125 cl = client.Client(self.instance, None, {'PATH_INFO':'/', |
| 126 'REQUEST_METHOD':'POST'}, makeForm(form)) | 126 'REQUEST_METHOD':'POST'}, makeForm(form)) |
| 127 cl.classname = classname | 127 cl.classname = classname |
| 128 cl.base = 'http://whoami.com/path/' | |
| 128 cl.nodeid = nodeid | 129 cl.nodeid = nodeid |
| 129 cl.language = ('en',) | 130 cl.language = ('en',) |
| 130 cl.userid = '1' | 131 cl.userid = '1' |
| 131 cl.db = self.db | 132 cl.db = self.db |
| 132 cl.user = 'admin' | 133 cl.user = 'admin' |
| 133 cl.template = template | 134 cl.template = template |
| 135 if env_addon is not None: | |
| 136 cl.env.update(env_addon) | |
| 134 return cl | 137 return cl |
| 135 | 138 |
| 136 def parseForm(self, form, classname='test', nodeid=None): | 139 def parseForm(self, form, classname='test', nodeid=None): |
| 137 cl = self.setupClient(form, classname, nodeid) | 140 cl = self.setupClient(form, classname, nodeid) |
| 138 return cl.parsePropsFromForm(create=1) | 141 return cl.parsePropsFromForm(create=1) |
| 805 status = '1', nosy = ['1'], keyword = ['1']) | 808 status = '1', nosy = ['1'], keyword = ['1']) |
| 806 self.db.commit () | 809 self.db.commit () |
| 807 form = {':note': 'msg-content', 'title': 'New title', | 810 form = {':note': 'msg-content', 'title': 'New title', |
| 808 'priority': '2', 'status': '2', 'nosy': '1,2', 'keyword': '', | 811 'priority': '2', 'status': '2', 'nosy': '1,2', 'keyword': '', |
| 809 'superseder': '5000', ':action': 'edit'} | 812 'superseder': '5000', ':action': 'edit'} |
| 810 cl = self.setupClient(form, 'issue', '1') | 813 cl = self.setupClient(form, 'issue', '1', |
| 814 env_addon = {'HTTP_REFERER': 'http://whoami.com/path/'}) | |
| 811 pt = RoundupPageTemplate() | 815 pt = RoundupPageTemplate() |
| 812 pt.pt_edit(page_template, 'text/html') | 816 pt.pt_edit(page_template, 'text/html') |
| 813 out = [] | 817 out = [] |
| 814 def wh(s): | 818 def wh(s): |
| 815 out.append(s) | 819 out.append(s) |
| 851 <p><input type="text" name="superseder" value="5000" size="30"></p> | 855 <p><input type="text" name="superseder" value="5000" size="30"></p> |
| 852 </body> | 856 </body> |
| 853 </html> | 857 </html> |
| 854 """.strip ()) | 858 """.strip ()) |
| 855 | 859 |
| 860 def testCsrfHeaderProtection(self): | |
| 861 # need to set SENDMAILDEBUG to prevent | |
| 862 # downstream issue when email is sent on successful | |
| 863 # issue creation. Also delete the file afterwards | |
| 864 # just tomake sure that someother test looking for | |
| 865 # SENDMAILDEBUG won't trip over ours. | |
| 866 if not os.environ.has_key('SENDMAILDEBUG'): | |
| 867 os.environ['SENDMAILDEBUG'] = 'mail-test1.log' | |
| 868 SENDMAILDEBUG = os.environ['SENDMAILDEBUG'] | |
| 869 | |
| 870 page_template = """ | |
| 871 <html> | |
| 872 <body> | |
| 873 <p tal:condition="options/error_message|nothing" | |
| 874 tal:repeat="m options/error_message" | |
| 875 tal:content="structure m"/> | |
| 876 <p tal:content="context/title/plain"/> | |
| 877 <p tal:content="context/priority/plain"/> | |
| 878 <p tal:content="context/status/plain"/> | |
| 879 <p tal:content="context/nosy/plain"/> | |
| 880 <p tal:content="context/keyword/plain"/> | |
| 881 <p tal:content="structure context/superseder/field"/> | |
| 882 </body> | |
| 883 </html> | |
| 884 """.strip () | |
| 885 self.db.keyword.create (name = 'key1') | |
| 886 self.db.keyword.create (name = 'key2') | |
| 887 nodeid = self.db.issue.create (title = 'Title', priority = '1', | |
| 888 status = '1', nosy = ['1'], keyword = ['1']) | |
| 889 self.db.commit () | |
| 890 form = {':note': 'msg-content', 'title': 'New title', | |
| 891 'priority': '2', 'status': '2', 'nosy': '1,2', 'keyword': '', | |
| 892 ':action': 'edit'} | |
| 893 cl = self.setupClient(form, 'issue', '1') | |
| 894 pt = RoundupPageTemplate() | |
| 895 pt.pt_edit(page_template, 'text/html') | |
| 896 out = [] | |
| 897 print "out1: ", id(out), out | |
| 898 def wh(s): | |
| 899 out.append(s) | |
| 900 cl.write_html = wh | |
| 901 # Enable the following if we get a templating error: | |
| 902 #def send_error (*args, **kw): | |
| 903 # import pdb; pdb.set_trace() | |
| 904 #cl.send_error_to_admin = send_error | |
| 905 # Need to rollback the database on error -- this usually happens | |
| 906 # in web-interface (and for other databases) anyway, need it for | |
| 907 # testing that the form values are really used, not the database! | |
| 908 # We do this together with the setup of the easy template above | |
| 909 def load_template(x): | |
| 910 cl.db.rollback() | |
| 911 return pt | |
| 912 cl.instance.templates.load = load_template | |
| 913 cl.selectTemplate = MockNull() | |
| 914 cl.determine_context = MockNull () | |
| 915 def hasPermission(s, p, classname=None, d=None, e=None, **kw): | |
| 916 return True | |
| 917 actions.Action.hasPermission = hasPermission | |
| 918 e1 = _HTMLItem.is_edit_ok | |
| 919 _HTMLItem.is_edit_ok = lambda x : True | |
| 920 e2 = HTMLProperty.is_edit_ok | |
| 921 HTMLProperty.is_edit_ok = lambda x : True | |
| 922 | |
| 923 # test with no headers and config by default requires 1 | |
| 924 cl.inner_main() | |
| 925 match_at=out[0].find('Unable to verify sufficient headers') | |
| 926 print out[0] | |
| 927 self.assertNotEqual(match_at, -1) | |
| 928 del(out[0]) | |
| 929 | |
| 930 # all the rest of these allow at least one header to pass | |
| 931 # and the edit happens with a redirect back to issue 1 | |
| 932 cl.env['HTTP_REFERER'] = 'http://whoami.com/path/' | |
| 933 cl.inner_main() | |
| 934 match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message') | |
| 935 self.assertEqual(match_at, 0) | |
| 936 del(cl.env['HTTP_REFERER']) | |
| 937 del(out[0]) | |
| 938 | |
| 939 cl.env['HTTP_ORIGIN'] = 'http://whoami.com' | |
| 940 cl.inner_main() | |
| 941 match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message') | |
| 942 self.assertEqual(match_at, 0) | |
| 943 del(cl.env['HTTP_ORIGIN']) | |
| 944 del(out[0]) | |
| 945 | |
| 946 cl.env['HTTP_X-FORWARDED-HOST'] = 'whoami.com' | |
| 947 # if there is an X-FORWARDED-HOST header it is used and | |
| 948 # HOST header is ignored. X-FORWARDED-HOST should only be | |
| 949 # passed/set by a proxy. In this case the HOST header is | |
| 950 # the proxy's name for the web server and not the name | |
| 951 # thatis exposed to the world. | |
| 952 cl.env['HTTP_HOST'] = 'frontend1.whoami.net' | |
| 953 cl.inner_main() | |
| 954 match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message') | |
| 955 self.assertNotEqual(match_at, -1) | |
| 956 del(cl.env['HTTP_X-FORWARDED-HOST']) | |
| 957 del(cl.env['HTTP_HOST']) | |
| 958 del(out[0]) | |
| 959 | |
| 960 cl.env['HTTP_HOST'] = 'whoami.com' | |
| 961 cl.inner_main() | |
| 962 match_at=out[0].find('Redirecting to <a href="http://whoami.com/path/issue1?@ok_message') | |
| 963 self.assertEqual(match_at, 0) | |
| 964 del(cl.env['HTTP_HOST']) | |
| 965 del(out[0]) | |
| 966 | |
| 967 # try failing headers | |
| 968 cl.env['HTTP_X-FORWARDED-HOST'] = 'whoami.net' | |
| 969 # this raises an error as the header check passes and | |
| 970 # it did the edit and tries to send mail. | |
| 971 cl.inner_main() | |
| 972 match_at=out[0].find('Invalid X-FORWARDED-HOST whoami.net') | |
| 973 self.assertNotEqual(match_at, -1) | |
| 974 del(out[0]) | |
| 975 | |
| 976 # clean up from email log | |
| 977 if os.path.exists(SENDMAILDEBUG): | |
| 978 os.remove(SENDMAILDEBUG) | |
| 979 #raise ValueError | |
| 980 | |
| 856 # | 981 # |
| 857 # SECURITY | 982 # SECURITY |
| 858 # | 983 # |
| 859 # XXX test all default permissions | 984 # XXX test all default permissions |
| 860 def _make_client(self, form, classname='user', nodeid='1', | 985 def _make_client(self, form, classname='user', nodeid='1', |
| 1279 | 1404 |
| 1280 # Test ok state template that uses user.forgotten.html | 1405 # Test ok state template that uses user.forgotten.html |
| 1281 self.client.form=makeForm({"@template": "forgotten|item"}) | 1406 self.client.form=makeForm({"@template": "forgotten|item"}) |
| 1282 self.client.path = 'user' | 1407 self.client.path = 'user' |
| 1283 self.client.determine_context() | 1408 self.client.determine_context() |
| 1409 self.client.session_api = MockNull(_sid="1234567890") | |
| 1284 self.assertEqual((self.client.classname, self.client.template, self.client.nodeid), ('user', 'forgotten|item', None)) | 1410 self.assertEqual((self.client.classname, self.client.template, self.client.nodeid), ('user', 'forgotten|item', None)) |
| 1285 self.assertEqual(self.client._ok_message, []) | 1411 self.assertEqual(self.client._ok_message, []) |
| 1286 | 1412 |
| 1287 result = self.client.renderContext() | 1413 result = self.client.renderContext() |
| 1288 self.assertNotEqual(-1, | 1414 self.assertNotEqual(-1, |
